springseuciryt oauth2基础
# 一、开发框架演进

随着技术的发展,新的前端渠道app出现了,而且随着应用部署方式的改变,前后端分离现在也很流行,前后端分离模式下,html就是一种前端的资源,不在和应用服务器部署在一起了,而是单独部署在WebServer上,比如nodejs。前后端分离模式用户访问的是WebServer,由WebServer访问Application Server,WebServer处理ajax请求和渲染返回的数据。这种模式下,访问应用服务器的不再是用户了而是第三方的应用。
发给用户一个令牌token,用户每次访问都拿着令牌,来判断用户登录信息权限等,token表现形式就是一个字符串。这样上边说的三个问题就可以解决了,token 不是通过cookie来携带的,而是http请求的参数,在请求头或者普通的参数都行;基于session的JESSIONID是服务器自己生成的,校验也是他自己校验,但是token的生成、校验我们可以自己控制,可以在token上加技术手段来增强安全性,可以实现token 刷新的方式而不会出现用户重复登录,缩短token有效时间增强安全性还保证了用户体验。
用令牌的方式在应用服务器和其他应用之间的认证和授权。这就很自然的联系到了OAuth协议了。OAuth协议就是用token的方式来做认证授权的:


绿色块,springsecurity已帮助我们完成,我们需要做的就是资源和自定义认证(图片验证码表单登陆、手机号表单登陆、oauth登陆)
# 二、Spring Security OAuth 简介
Spring Social 实际上是封装了第三方应用(Client)所要做的大多数事情,拿着Spring Social可以很快开发一个第三方应用来连接想要连接的服务提供商,而Spring Security OAuth和Spring Social 正好相反,Spring Security OAuth 封装了服务提供商的大部分行为,用来快速搭建服务提供商的程序,发放令牌、校验令牌。
要实现服务提供商,其实就是要实现两个服务器 :认证服务器、资源服务器
# 认证服务器:
**实现四种授权模式:**来确认用户身份以及拥有的权限。Spring Security OAuth 里已实现了四种授权模式
**token的生成和存储:**根据信息生成令牌token,资源服务器根据token拿资源。OAuth协议没有规定token的具体生成方式,Spring Security OAuth 提供了默认的实现。
# 资源服务器:
保护资源,在我们的场景,资源就是Rest 服务。Spring Security 是一系列的过滤器链,Spring Security OAuth就是加了个OAuth2AuthenticationFilter
的过滤器,从请求中拿出来发出去的token,根据配置的存储策略从存储里根据token拿出用户信息,根据用户信息是否存在、是否有权限等来判断是否能访问要访问的 服务。
# 自己实现服务提供商:
要处理的问题是,我们不希望用户走标准的四种授权模式的,如手机号+短信验证码登录方式和标准四种授权模式是对应不上的。我们需要做的是让自定义的认证方式可以嫁接到认证服务器上,让用户通过自己的认证方式也可以调用token 生成的机制生成token发给第三方应用,第三方应用存储这个token ,每次访问服务带上这个token 经过过滤器链上的过滤器通过认证、授权来访问服务。
1、实现一个标准的OAuth2协议中Provider(服务提供商:认证服务器、资源服务器)角色的主要功能
2、重构之前的认证方式,使其支持token
3、自定义 token的生成方式
# 参考文章:
# 三、开发认证服务器
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
2
3
4
@Configuration
@EnableAuthorizationServer //这个注解就是实现了一个认证服务器
public class ImoocAuthenticationServerConfig {
}
2
3
4
5
@EnableAuthorizationServer
注解就说明该项目是一个 认证服务器,项目启动时,会打印出client-id,/oauth/token、/oauth/token_key、 /oauth/check_token
2021-07-09 17:22:30.427 INFO 10104 --- [ main] a.OAuth2AuthorizationServerConfiguration : Initialized OAuth2 Client
security.oauth2.client.client-id = f6e825b7-b51a-4646-abcc-ec36baca0394
security.oauth2.client.client-secret = d06ebb34-ad51-462d-b6cf-81784d51fa83
o.s.s.web.DefaultSecurityFilterChain : Will secure Or [Ant [pattern='/oauth/token'], Ant [pattern='/oauth/token_key'], Ant [pattern='/oauth/check_token']] with [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@7316742f, org.springframework.security.web.context.SecurityContextPersistenceFilter@7e667bfb, org.springframework.security.web.header.HeaderWriterFilter@5640a424, org.springframework.security.web.authentication.logout.LogoutFilter@1775ff4a, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@4c48564d, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@1124fd8, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@18ae3583, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@6c73d88c, org.springframework.security.web.session.SessionManagementFilter@7a0eca26, org.springframework.security.web.access.ExceptionTranslationFilter@30d14f84, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@523c4a6d]
2
3
4
5
6
服务提供商需要提供两个服务:
1,用户调过来点击授权的地址 (如qq微信登录的授权页)/oauth/authorize
2,点完授权后带着授权码换取access_token的地址(对用户不可见)
加上了@EnableAuthorizationServer
注解的项目,启动后控制台会打印如下的信息:
/oauth/authorize
就是让用户授权的地址,启动应用,访问/oauth/authorize,需要一些参数:
response_type:表示授权类型,必选项,此处的值固定为"code"
client_id:表示客户端的ID,必选项
redirect_uri:表示重定向URI,可选项
scope:表示申请的权限范围,自定义,可选项
state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。
# starter分析
OAuth2AutoConfiguration: 注册了OAuth2AuthorizationServerConfiguration、OAuth2ResourceServerConfiguration、OAuth2ClientProperties、ResourceServerProperties
@Configuration
@ConditionalOnClass({ OAuth2AccessToken.class, WebMvcConfigurer.class })
@Import({ OAuth2AuthorizationServerConfiguration.class,
OAuth2MethodSecurityConfiguration.class, OAuth2ResourceServerConfiguration.class,
OAuth2RestOperationsConfiguration.class })
@AutoConfigureBefore(WebMvcAutoConfiguration.class)
@EnableConfigurationProperties(OAuth2ClientProperties.class)
public class OAuth2AutoConfiguration {
private final OAuth2ClientProperties credentials;
public OAuth2AutoConfiguration(OAuth2ClientProperties credentials) {
this.credentials = credentials;
}
@Bean
public ResourceServerProperties resourceServerProperties() {
return new ResourceServerProperties(this.credentials.getClientId(),
this.credentials.getClientSecret());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1、授权码模式
# 实现授权码模式第一步:获取授权码
配置client-id, 允许哪些授权模式,scopes等
@Configuration
@EnableAuthorizationServer
public class MyAuthorizationServerServerConfig extends AuthorizationServerConfigurerAdapter {
//配置客户端
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
//client的id和密码
.withClient("client1")
.secret(passwordEncoder.encode("123123"))
//给client一个id,这个在client的配置里要用的
.resourceIds("resource1")
//允许的申请token的方式,测试用例在test项目里都有.
//authorization_code授权码模式,这个是标准模式
//implicit简单模式,这个主要是给无后台的纯前端项目用的
//password密码模式,直接拿用户的账号密码授权,不安全
//client_credentials客户端模式,用clientid和密码授权,和用户无关的授权方式
//refresh_token使用有效的refresh_token去重新生成一个token,之前的会失效
.authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")
//授权的范围,每个resource会设置自己的范围.
.scopes("scope1")
//这个是设置要不要弹出确认授权页面的.
.autoApprove(false)
//这个相当于是client的域名,重定向给code的时候会跳转这个域名
.redirectUris("http://www.baidu.com")
.and()
//在spring cloud的测试中,我们有两个资源服务,这里也给他们配置两个client,并分配不同的scope.
.withClient("client2")
.secret(passwordEncoder.encode("123123"))
.resourceIds("resource2")
.authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")
.scopes("scope2")
.autoApprove(false)
.redirectUris("http://www.sogou.com");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
会要求先登陆,跳到/login, 登陆后,到是否允许授权页,如图。默认情况下,认证服务器上请求需要有ROLE_USER的角色才能访问授权页。
# 第二步授权码换取access_token:
此时就该拿着授权码去获取access_token了,post请求 /oauth/token ,使用Resetlet Client,在请求头里新建一个Authentication(Authorization 设置HTTP身份验证的凭证https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Authorization),用户名输入imooc,密码输入 imoocsecret
客户端向认证服务器申请令牌的HTTP请求,包含以下参数:
grant_type:表示使用的授权模式,必选项,此处的值固定为"authorization_code"。
code:表示上一步获得的授权码,必选项。
redirect_uri:表示重定向URI,必选项,且必须与上边步骤中的该参数值保持一致。
client_id:表示客户端ID,必选项。
client_secret:客户端密钥
同时header里面,需要加上 client_id和client_secret组成的
Basic Auth
, Authorization里面的username就填client_id
完整的请求:
参考文章:
oauth2授权码模式官方英文介绍 (ietf.org) (opens new window)
# 2、密码模式
/oauth/token 参数是用户名密码,其实是用户把在服务提供商上的用户名/密码告诉了第三方,第三方拿着用户名密码去给服务提供商说用户已经授权了。
在app场景下是可用的,因为第三方是app,是公司前端,提供商是服务端,都是一伙的,不涉及安全问题。 请求参数如下
grant_type:表示使用的授权模式,必选项,此处的值固定为
password
。username 数据库用户登陆用户名
password 用户登陆密码
scope
同时header里面,需要加上 client_id和client_secret组成的 Basic Auth
, Authorization里面的username就填client_id
{
"error": "unsupported_grant_type",
"error_description": "Unsupported grant type: password"
}
2
3
4
返回Unsupported grant type: password
, 则需要在认证服务器中设置 中配置AuthenticationManager
@Configuration
@EnableAuthorizationServer
@RequiredArgsConstructor
public class MyAuthorizationServerServerConfig extends AuthorizationServerConfigurerAdapter {
private final AuthenticationManager authenticationManager;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//添加客户端信息
clients.inMemory()
.withClient("zxp")
.secret("zxp123")
.scopes("all")
.redirectUris("http://www.baidu.com")
.authorizedGrantTypes("password", "authorization_code", "refresh_token", "implicit");
}
/**
* Spring security5中新增加了加密方式,并把原有的spring security的密码存储格式改了
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
... 省略了
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
2
3
4
5
6
7
8
9
10
11
参考文章:
OAuth2密码模式提示Unsupported grant type: password - 灰信网(软件开发博客聚合) (freesion.com) (opens new window)
# 3、客户端授权模式
只需要传客户端id和client_secret即可,不需要用户名和密码
# 3、token自动刷新
# 将token保存到数据库
# 1、建表
官方给了个sql文件:https://github.com/spring-projects/spring-security-oauth/blob/master/spring-security-oauth2/src/test/resources/schema.sql,这个sql文件不能在mysql中直接执行,要把sql中的256修改为128,LONGVARBINARY修改为BLOB
create table oauth_client_details (
client_id VARCHAR(128) PRIMARY KEY,
resource_ids VARCHAR(128),
client_secret VARCHAR(128),
scope VARCHAR(128),
authorized_grant_types VARCHAR(128),
web_server_redirect_uri VARCHAR(128),
authorities VARCHAR(128),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information VARCHAR(4096),
autoapprove VARCHAR(128)
);
create table oauth_client_token (
token_id VARCHAR(128),
token BLOB,
authentication_id VARCHAR(128) PRIMARY KEY,
user_name VARCHAR(128),
client_id VARCHAR(128)
);
create table oauth_access_token (
token_id VARCHAR(128),
token BLOB,
authentication_id VARCHAR(128) PRIMARY KEY,
user_name VARCHAR(128),
client_id VARCHAR(128),
authentication BLOB,
refresh_token VARCHAR(128)
);
create table oauth_refresh_token (
token_id VARCHAR(128),
token BLOB,
authentication BLOB
);
create table oauth_code (
code VARCHAR(128), authentication BLOB
);
create table oauth_approvals (
userId VARCHAR(128),
clientId VARCHAR(128),
scope VARCHAR(128),
status VARCHAR(10),
expiresAt TIMESTAMP,
lastModifiedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- customized oauth_client_details table
create table ClientDetails (
appId VARCHAR(128) PRIMARY KEY,
resourceIds VARCHAR(128),
appSecret VARCHAR(128),
scope VARCHAR(128),
grantTypes VARCHAR(128),
redirectUrl VARCHAR(128),
authorities VARCHAR(128),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additionalInformation VARCHAR(4096),
autoApproveScopes VARCHAR(128)
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
插入客户端数据,密码为123123
INSERT INTO `oauth_client_details` VALUES ('client1', 'resource1', '$2a$10$YEpRG0cFXz5yfC/lKoCHJ.83r/K3vaXLas5zCeLc.EJsQ/gL5Jvum', 'scope1', 'authorization_code,password,client_credentials,implicit,refresh_token', 'http://www.baidu.com', null, '300', '1500', null, 'false');
INSERT INTO `oauth_client_details` VALUES ('client2', 'resource2', '$2a$10$YEpRG0cFXz5yfC/lKoCHJ.83r/K3vaXLas5zCeLc.EJsQ/gL5Jvum', 'scope2', 'authorization_code,password,client_credentials,implicit,refresh_token', 'http://www.163.com', null, '300', '1500', null, 'false');
2
3
# 2、写配置
@Configuration
//开启oauth2,auth server模式
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
//首先要建表,官方给了个sql文件:https://github.com/spring-projects/spring-security-oauth/blob/master/spring-security-oauth2/src/test/resources/schema.sql
//这个sql文件不能在mysql中直接执行,要把sql中的256修改为128,LONGVARBINARY修改为BLOB
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private ClientDetailsService clientDetailsService;
//密码模式才需要配置,认证管理器
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private DataSource dataSource;
//配置客户端
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//配置客户端存储到db
JdbcClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
clientDetailsService.setPasswordEncoder(passwordEncoder);
clients.withClientDetails(clientDetailsService);
}
//配置token管理服务
@Bean
public AuthorizationServerTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setClientDetailsService(clientDetailsService);
defaultTokenServices.setSupportRefreshToken(true);
//配置token的存储方法
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setAccessTokenValiditySeconds(300);
defaultTokenServices.setRefreshTokenValiditySeconds(1500);
//配置token增加,把一般token转换为jwt token
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenConverter()));
defaultTokenServices.setTokenEnhancer(tokenEnhancerChain);
return defaultTokenServices;
}
//把上面的各个组件组合在一起
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)//认证管理器
//配置授权码存储到db
.authorizationCodeServices(new JdbcAuthorizationCodeServices(dataSource))//授权码管理
.tokenServices(tokenServices())//token管理
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}
//配置哪些接口可以被访问
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.tokenKeyAccess("permitAll()")///oauth/token_key公开
.checkTokenAccess("permitAll()")///oauth/check_token公开
.allowFormAuthenticationForClients();//允许表单认证
}
//配置如何把普通token转换成jwt token
@Bean
public JwtAccessTokenConverter tokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
//使用对称秘钥加密token,resource那边会用这个秘钥校验token
converter.setSigningKey("uaa123");
return converter;
}
//配置token的存储方法
@Bean
public TokenStore tokenStore() {
//配置token存储在内存中,这种是普通token,每次都需要远程校验,性能较差
//return new JdbcTokenStore(dataSource);
//把用户信息都存储在token当中,相当于存储在客户端,性能好很多
return new JwtTokenStore(tokenConverter());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 四、资源服务器
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2
3
4
5
6
7
8
只需要添加注解@EnableResourceServer
,即可变成资源服务器,请求过来的时候,利用授权模式拿 到token, 再在请求头中加上token, 即可访问到资源服务器里面的资源
- token默认放在内存中,重启应用会失效,也可以存放在数据库当中,
@Configuration
//开启oauth2,reousrce server模式
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
//远程token验证, 普通token必须远程校验
RemoteTokenServices tokenServices = new RemoteTokenServices();
//配置去哪里验证token
tokenServices.setCheckTokenEndpointUrl("http://127.0.0.1:3001/oauth/check_token");
//配置组件的clientid和密码,这个也是在auth中配置好的
tokenServices.setClientId("client1");
tokenServices.setClientSecret("123123");
resources
//设置我这个resource的id, 这个在auth中配置, 这里必须照抄
.resourceId("resource1")
.tokenServices(tokenServices)
//这个貌似是配置要不要把token信息记录在session中
.stateless(true);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
//本项目所需要的授权范围,这个scope是写在auth服务的配置里的
.antMatchers("/**").access("#oauth2.hasScope('scope1')")
.and()
//这个貌似是配置要不要把token信息记录在session中
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
因为每次都要到认证服务器进行token验证,这里更改成jwt token
jwt token不需要保存到数据库中
@Configuration
//开启oauth2,reousrce server模式
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//配置如何把普通token转换成jwt token
@Bean
public JwtAccessTokenConverter tokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
//使用对称秘钥加密token,resource那边会用这个秘钥校验token
converter.setSigningKey("uaa123");
return converter;
}
//配置token的存储方法
@Bean
public TokenStore tokenStore() {
//把用户信息都存储在token当中,相当于存储在客户端,性能好很多
return new JwtTokenStore(tokenConverter());
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
//设置我这个resource的id, 这个在auth中配置, 这里必须照抄
.resourceId("resource1")
.tokenStore(tokenStore())
//这个貌似是配置要不要把token信息记录在session中
.stateless(true);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
//由于在zuul已经做了scope的校验,这里可以不写了.当然你想写上也是没有问题的
.antMatchers("/**").permitAll()//.access("#oauth2.hasScope('scope1')")
.and()
//这个貌似是配置要不要把token信息记录在session中
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 五、springsecurity oauth2核心源码
实现OAuth 2.0授权服务器,Spring Security过滤器链中需要以下端点:
- AuthorizationEndpoint 用于服务于授权请求。预设地址:/oauth/authorize。
- TokenEndpoint 用于服务访问令牌的请求。预设地址:/oauth/token。
实现OAuth 2.0资源服务器,需要以下过滤器:
- OAuth2AuthenticationProcessingFilter 用于加载给定的认证访问令牌请求的认证。
# 一、 @EnableAuthorizationServer 解析
我们都知道 一个授权认证服务器最最核心的就是 @EnableAuthorizationServer , 那么 @EnableAuthorizationServer 主要做了什么呢? 我们看下 @EnableAuthorizationServer 源码:
@Import({AuthorizationServerEndpointsConfiguration.class, AuthorizationServerSecurityConfiguration.class})
public @interface EnableAuthorizationServer {
}
2
3
# AuthorizationServerEndpointsConfiguration:
注册了TokenKeyEndpointRegistrar、AuthorizationEndpoint、TokenEndpoint、CheckTokenEndpoint、FactoryBean
# AuthorizationServerSecurityConfiguration
注册了ClientDetailsServiceConfiguration (里面又注册了ClientDetailsServiceConfigurer、ClientDetailsService)
# 二、 @EnableResourceServer 解析
像授权认证服务器一样,资源服务器也有一个最核心的配置 @EnableResourceServer , 那么 @EnableResourceServer 主要做了什么呢? 我们 一样先看下 @EnableResourceServer 源码:
# 三、 AuthorizationEndpoint 解析
正如前面介绍一样,AuthorizationEndpoint 本身 最大的功能点就是实现了 /oauth/authorize , 那么我们这次就来看看它是如何实现的:
# 四、 TokenEndpoint 解析
对于使用oauth2 的用户来说,最最不可避免的就是token 的获取,/oauth/token
请求就会到这个类上,话不多说,核心源码解析贴上:
@FrameworkEndpoint
public class TokenEndpoint extends AbstractEndpoint {
private OAuth2RequestValidator oAuth2RequestValidator = new DefaultOAuth2RequestValidator();
private Set<HttpMethod> allowedRequestMethods = new HashSet<HttpMethod>(Arrays.asList(HttpMethod.POST));
@RequestMapping(value = "/oauth/token", method=RequestMethod.GET)
public ResponseEntity<OAuth2AccessToken> getAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
if (!allowedRequestMethods.contains(HttpMethod.GET)) {
throw new HttpRequestMethodNotSupportedException("GET");
}
return postAccessToken(principal, parameters);
}
@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
if (!(principal instanceof Authentication)) {
throw new InsufficientAuthenticationException(
"There is no client authentication. Try adding an appropriate authentication filter.");
}
String clientId = getClientId(principal);
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
if (clientId != null && !clientId.equals("")) {
// Only validate the client details if a client authenticated during this
// request.
if (!clientId.equals(tokenRequest.getClientId())) {
// double check to make sure that the client ID in the token request is the same as that in the
// authenticated client
throw new InvalidClientException("Given client ID does not match authenticated client");
}
}
if (authenticatedClient != null) {
oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
}
if (!StringUtils.hasText(tokenRequest.getGrantType())) {
throw new InvalidRequestException("Missing grant type");
}
if (tokenRequest.getGrantType().equals("implicit")) {
throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
}
if (isAuthCodeRequest(parameters)) {
// The scope was requested or determined during the authorization step
if (!tokenRequest.getScope().isEmpty()) {
logger.debug("Clearing scope of incoming token request");
tokenRequest.setScope(Collections.<String> emptySet());
}
}
if (isRefreshTokenRequest(parameters)) {
// A refresh token has its own default scopes, so we should ignore any added by the factory here.
tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE)));
}
OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
if (token == null) {
throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
}
return getResponse(token);
}
... 省略
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
1、核心逻辑就一行 OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
2、找到grant方法的实现在抽象类AbstractTokenGranter
当中,其又调用 getAccessToken(client, tokenRequest);
拆解里面的两个方法getOAuth2Authentication()
交由子类实现,不同授权模式实现不一样; 和 createAccessToken()
protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
}
2
3
3、getOAuth2Authentication(),不同的授权模式,获取的方式不一样,会调用authenticationManager
,最终调用UserDetailService方法拿到用户数据,见ResourceOwnerPasswordTokenGranter
源码62行左右
4、tokenServices即为AuthorizationServerTokenServices
(实现类DefaultTokenServices
)进行创建OAuth2AccessToken,默认为uuid,也可以用TokenEnhancer
增强器生成
@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
// 先从tokenStore里面去拿令牌,如果有发过,就不用重新生成了
OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
... 省略了部分代码
OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
tokenStore.storeAccessToken(accessToken, authentication);
// In case it was modified
refreshToken = accessToken.getRefreshToken();
if (refreshToken != null) {
tokenStore.storeRefreshToken(refreshToken, authentication);
}
return accessToken;
}
private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
// 默认生成的token为uuid
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
if (validitySeconds > 0) {
token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
}
token.setRefreshToken(refreshToken);
token.setScope(authentication.getOAuth2Request().getScope());
//可以使用token增强器生成token
return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
简单概括下来,整个生成token 的逻辑如下:
- 1、 验证用户信息 (正常情况下会经过 ClientCredentialsTokenEndpointFilter 过滤器认证后获取到用户信息 )
- 2、 通过 ClientDetailsService().loadClientByClientId() 获取系统配置的客户端信息
- 3、 通过客户端信息生成 TokenRequest 对象
- 4、 将步骤3获取到的 TokenRequest 作为TokenGranter.grant() 方法参照 生成 OAuth2AccessToken 对象(即token)
- 5、 返回 token
# 五、 OAuth2AuthenticationProcessingFilter (资源服务器认证)解析
通过前面的解析我们最终获取到了token,但获取token 不是我们最终目的,我们最终的目的时拿到资源信息,所以我们还得通过获取到的token去调用资源服务器接口获取资源数据。那么接下来我们就来解析资源服务器是如何通过传入token去辨别用户并允许返回资源信息的。我们知道资源服务器在过滤器链新增了 OAuth2AuthenticationProcessingFilter
来拦截请求并认证,那就这个过滤器的实现:
整个filter步骤最核心的是下面2个:
- 1、 调用 tokenExtractor.extract() 方法从请求中解析出token信息并存放到 authentication 的 principal 字段 中
- **2、 调用 authenticationManager.authenticate() 认证过程: 注意此时的 authenticationManager 是 OAuth2AuthenticationManager **
在解析@EnableResourceServer 时我们讲过 OAuth2AuthenticationManager 与 OAuth2AuthenticationProcessingFilter 的关系,这里不再重述,我们直接看下 OAuth2AuthenticationManager 的 authenticate() 方法实现:
整个 认证逻辑分4步:
- 1、 从 authentication 中获取 token
- 2、 调用 tokenServices.loadAuthentication() 方法 通过 token 参数获取到 OAuth2Authentication 对象 ,这里的tokenServices 就是我们资源服务器配置的。
- 3、 检测客户端信息,由于我们采用授权服务器和资源服务器分离的设计,所以这个检测方法实际没有检测
- 4、 设置认证成功标识并返回 ,注意返回的是 OAuth2Authentication (Authentication 子类)。
后面的授权过程就是原汁原味的Security授权,所以至此整个资源服务器 通过获取到的token去调用接口获取资源数据 的解析完成。
# 六、 重写登陆,实现登录接口直接返回jwtToken
前面,我们花了大量时间讲解,那么肯定得实践实践一把。 相信大家平时的登录接口都是直接返回token的,但是由于Security 最原本的设计原因,登陆后都是跳转回到之前求情的接口,这种方式仅仅适用于PC端,那如果是APP呢?所以我们想要在原有的登陆接口上实现当非PC请求时返回token的功能。还记得之前提到过的 AuthenticationSuccessHandler 认证成功处理器,我们的功能实现就在这里面。
我们重新回顾下 /oauth/authorize 实现 token,模仿实现后的代码如下:
顾下创建token 需要的 几个必要类:clientDetailsService 、 authorizationServerTokenServices、 ClientDetails 、 TokenRequest 、OAuth2Request、 authentication、OAuth2Authentication 。 了解这几个类之间的关系很有必要。对于clientDetailsService 、 authorizationServerTokenServices 我们可以直接从Spring 容器中获取,ClientDetails 我们可以从请求参数中获取,有了 ClientDetails 就有了 TokenRequest,有了 TokenRequest 和 authentication(认证后肯定有的) 就有了 OAuth2Authentication ,有了OAuth2Authentication 就能够生成 OAuth2AccessToken。 至此,我们通过直接请求登陆接口(注意在请求头中添加ClientDetails信息)就可以实现获取到token了,那么有同学会问,如果我是手机登陆方式呢?其实不管你什么登陆方式,只要你设置的登陆成功处理器是上面那个就可支持
# 六、重构登陆后发令牌
授权码模式进入TokenGranter那一部分无法修改代码 ,因此从逻辑链的后边入手。
# 修改认证成功处理器
/**
* 认证成功处理器
* @author: shollin
* @date: 2021/7/6/006 6:52
*/
//@Component
@Slf4j
@RequiredArgsConstructor
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final ObjectMapper objectMapper;
private final SecurityProperties securityProperties;
private final ClientDetailsService clientDetailsService;
private final AuthorizationServerTokenServices authorizationServerTokenServices;
private BasicAuthenticationConverter authenticationConverter = new BasicAuthenticationConverter();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String contentType = request.getContentType();
log.info("验证成功!{}",contentType);
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isNotBlank(header)) {
// 发放令牌
// 第一步:从header里面解析出client_id 和client_secret,参考BasicAuthenticationFilter
UsernamePasswordAuthenticationToken authRequest = authenticationConverter.convert(request);
if (authRequest == null) {
this.logger.trace("Did not process authentication request since failed to find username and password in Basic Authorization header");
throw new UnapprovedClientAuthenticationException("client_id不存在");
}
String clientId = authRequest.getName();
String clientSecret = (String) authRequest.getCredentials();
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if(clientDetails==null){
throw new UnapprovedClientAuthenticationException("client_id对应的匹配令牌不存在:"+clientId);
}else if(!clientSecret.equals(clientDetails.getClientSecret())){
throw new UnapprovedClientAuthenticationException("client_secret不匹配"+clientId);
}
// 第二步: 生成OAuth2Authentication,参考源码:TokenEndpoint、ResourceOwnerPasswordTokenGranter
String grantType="custom";
TokenRequest tokenRequest = new TokenRequest(MapUtil.empty(), clientId, clientDetails.getScope(),grantType);
OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);
// 第三步:调用AuthorizationServerTokenServices创建令牌,参考源码: AbstractTokenGranter
OAuth2AccessToken accessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);
WebUtil.renderJson(response,R.data(accessToken));
return;
}
if (WebUtil.isAjaxRequest(request)) {
WebUtil.renderJson(response,R.data(authentication));
} else {
// 存到session里,方便取值。
request.getSession().setAttribute("authentication", authentication);
// 支持跳转到自定义页面
// 会帮我们跳转到上一次请求的页面上
super.onAuthenticationSuccess(request, response, authentication);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 写资源服务器配置
@Configuration
@RequiredArgsConstructor
@EnableResourceServer
public class MyResouceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private ValidateCodeFilter validateCodeFilter;
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private MobileAuthenticationSecurityConfig mobileAuthenticationSecurityConfig;
@Override
public void configure(HttpSecurity http) throws Exception {
// 所有的请求都需要认证
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated();
// 表单登陆配置
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.formLogin()
.loginPage("/login") // 登陆页get请求login, 登陆post请求login
.permitAll()
.successHandler(authenticationSuccessHandler) // 表单登陆成功、失败处理器
.failureHandler(authenticationFailureHandler);
// 安全策略配置
http.csrf().disable();
http.cors();
http.sessionManagement().disable();
http.apply(mobileAuthenticationSecurityConfig);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
可以看到FilterChainProxy
过滤器链上加多了OAuth2AuthenticationProcessingFilter
# 重写验证码的保存逻辑
验证码需要保存到redis当中,而不能放在session里面,用curl
工具命令行(git命令窗口)发送请求进行测试
# 七、Token定制化处理
在认证服务器里面进行配置修改
# 1、Token参数配置
# 将token保存到redis当中
@Configuration()
@Slf4j
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class TokenConfig {
/**
* redis工厂,默认使用lettue
*/
@Autowired
public RedisConnectionFactory redisConnectionFactory;
@Autowired
public RedisProperties redisProperties;
@Bean
public TokenStore tokenStore() {
// 这里是RedissonConnection
log.info("redisConnectionFactory: "+redisConnectionFactory.getConnection());
//使用redis存储token
RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
//设置redis token存储中的前缀
redisTokenStore.setPrefix("zxpAuth-token:");
return redisTokenStore;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Autowired
private TokenStore tokenStore;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
2
3
4
5
6
7
8
9
因为项目里面用了redisson锁,其starter用的RedisConnectionFactory,就是RedissonConnectionFactory
# 2、设置保存的位置、过期时间、授权模式等
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//添加客户端信息
clients.inMemory()
.withClient("zxp")
.secret("zxp123")
.scopes("all","read","write")
.redirectUris("http://www.baidu.com")
.authorizedGrantTypes("password", "authorization_code", "refresh_token", "implicit")
.accessTokenValiditySeconds(7200); // 过期时间,单位秒
}
2
3
4
5
6
7
8
9
10
11
# 2、JWT替换默认Token
默认生成的token为没有意义的uuid,见源码 DefaultTokenServices
293行。
JWT特点:
# 自包含:
可以包含相关的用户信息,最基本的数据关联,用户执行相关操作时需要有对应的权限,此时请求头中会携带Token,对Token进行解析就可以获取自己定义的相关信息,例如userId,而不必根据Token再去查询相关联的数据。
# 密签:
签名用于验证消息在此过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证JWT的发送者是它所说的真实身份。
# 可扩展:可以自定义相关参数
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
//处理token生成逻辑
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
// 设置签名密钥
jwtAccessTokenConverter.setSigningKey(securityProperties.getJwt().getSecret());
return jwtAccessTokenConverter;
}
// 自定义增强器,添加一些额外的token数据
@Bean
public TokenEnhancer tokenEnhancer() {
return new MyJwtTokenEnhancer();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
log.info("注入的tokenStore:"+tokenStore);
endpoints.tokenStore(tokenStore)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
// 如果配置了jwt方式生成token,则加上这个
if(jwtAccessTokenConverter!=null){
endpoints.accessTokenConverter(jwtAccessTokenConverter);
}
}
2
3
4
5
6
7
8
9
10
11
12
生成的jwt token可以在线解析: JSON Web Tokens - jwt.io (opens new window)
// jwt token的话,@AuthenticationPrincipal UserDetails拿不到
@GetMapping("/me")
public Object me(Authentication user) {
return user;
}
2
3
4
5
用jwt获取到的数据,不能用 @AuthenticationPrincipal UserDetails user 注解拿
# 3、扩展及解析Token
jwt的token里面可以添加自定义的信息,但是security在解析成Authentication时,并不认识新添加的信息,需要自己去解析token.
public class MyJwtTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> map = new HashMap<>();
MySocialUser mySocialUser = (MySocialUser) authentication.getPrincipal();
// 从数据库中根据手机号之类信息查询出
map.put("userId", mySocialUser.getId());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
return accessToken;
}
}
2
3
4
5
6
7
8
9
10
11
12
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
log.info("注入的tokenStore:"+tokenStore);
endpoints.tokenStore(tokenStore)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
// 如果配置了jwt方式生成token,则加上这个
if(jwtAccessTokenConverter!=null && tokenEnhancer!=null){
TokenEnhancerChain chain = new TokenEnhancerChain();
ArrayList<TokenEnhancer> list = Lists.newArrayList();
list.add(jwtAccessTokenConverter);
list.add(tokenEnhancer);
chain.setTokenEnhancers(list);
endpoints
.tokenEnhancer(chain)
.accessTokenConverter(jwtAccessTokenConverter);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 从token中解析出当前账号标识
*/
public static String getUserId() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String header = request.getHeader("Authorization");
String token = StringUtils.substringAfter(header, "bearer ");
Claims claims = null;
// 用户在系统内标识
String userId = null;
try {
claims = Jwts.parser().setSigningKey("mydev".getBytes("UTF-8")).parseClaimsJws(token).getBody();
userId = (String) claims.get("userId");
} catch (Exception e) {
userId = null;
}
return userId;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// jwt token的话,@AuthenticationPrincipal UserDetails拿不到
@GetMapping("/me")
public Object me(Authentication user, HttpServletRequest request) throws Exception {
String authorization = request.getHeader(Header.AUTHORIZATION.getValue());
if(StrUtil.isNotBlank(authorization)){
String token = StrUtil.subAfter(authorization,"bearer ", true);
Claims claims = Jwts.parser().setSigningKey(securityProperties.getJwt().getSecret().getBytes(CharsetUtil.UTF_8))
.parseClaimsJws(token).getBody();
Object author = claims.get("author");
log.info("作者:"+author);
}
return user;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
如果是普通uuid的token, 资源服务器需要将token发送到认证服务器进行token的验证,会比较影响性能。 改造成Jwt形式的token后,因为token中就包括了用户数据,不需要进行验证。
源码参考: lansinuote/Spring-Oauth2-Toturials: 使用Spring Oauth2做分布式鉴权 (github.com) (opens new window)
参考文章:
Spring Security(十五):Token配置_monday的博客-CSDN博客 (opens new window)
SpringSecurity之令牌配置_single_cong的博客-CSDN博客 (opens new window)
# 八、基于jwt实现sso单点登陆
jwt格式:以点.进行分割成三段,第一段为头,第二段为内容,第三段为签名信息(防止篡改)。
参考文章:
基于JWT实现SSO单点登录 - 明月之诗 - 博客园 (cnblogs.com) (opens new window)
# 九、整合gateway
参考文章:
SpringCloud Alibaba微服务实战十四 - Gateway集成Oauth2.0 - 知乎 (zhihu.com) (opens new window)
微服务权限终极解决方案,Spring Cloud Gateway + Oauth2 实现统一认证和鉴权! (juejin.cn) (opens new window)
参考文章:
Spring Security OAuth (opens new window)
Spring Security 解析(七) —— Spring Security Oauth2 源码解析 - 想飞的猿 - 博客园 (cnblogs.com) (opens new window)
参考文章:
SpringSecurityOAuth开发app认证框架_whaleluo的博客-CSDN博客_认证框架 (opens new window)
SpringSecurity结合OAuth2实现第三方授权_攻城老湿的博客-CSDN博客 (opens new window)