前言

OAuth(开放授权)是一个开放标准,允许用户授权第三方移动应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容,OAuth2.0是OAuth协议的延续版本,但不向后兼容OAuth 1.0即完全废止了OAuth1.0。

Spring Security OAuth2.0即利用Spring Security框架对OAuth2标准的一种实现。

OAuth2.0认证流程

引自OAauth2.0协议rfc6749 https://tools.ietf.org/html/rfc6749

OAauth2.0包括以下角色:

  1. 客户端:本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:Android客户端、Web客户端(浏览器端)、微信客户端等。
  2. 资源拥有者:通常为用户,也可以是应用程序,即该资源的拥有者。
  3. 授权服务器(也称认证服务器):用于服务提供商对资源拥有的身份进行认证、对访问资源进行授权,认证成功后会给客户端发放令牌(access_token),作为客户端访问资源服务器的凭据。
  4. 资源服务器:存储资源的服务器,本例子为微信存储的用户信息。

现在还有一个问题,服务提供商能允许随便一个客户端就接入到它的授权服务器吗?答案是否定的,服务提供商会给准入的接入方一个身份,用于接入时的凭据:

  • client_id:客户端标识
  • client_secret:客户端秘钥

因此,准确来说,授权服务器对两种OAuth2.0中的两个角色进行认证授权,分别是资源拥有者客户端

环境介绍

Spring-Security-OAuth2是对OAuth2的一种实现,与Spring Security相辅相成,而且与Spring Cloud体系的集成也非常便利。OAuth2.0的服务提供方涵盖两个服务,即授权服务(Authorization Server,也叫认证服务)和资源服务(Resource Server),你可以选择把它们在同一个应用程序中实现,也可以选择建立使用同一个授权服务的多个资源服务。

  • 授权服务(Authorization Server):应包含对接入端以及登入用户的合法性进行验证并颁发token等功能,对令牌的请求端点由 Spring MVC 控制器进行实现,下面是配置一个认证服务必须要实现的endpoints:
    • AuthorizationEndpoint 服务于认证请求,默认 URL: /oauth/authorize
    • TokenEndpoint 服务于访问令牌的请求,默认 URL:/oauth/token
  • 资源服务(Resource Server):应包含对资源的保护功能,对非法请求进行拦截,对请求中token进行解析鉴权等,下面的过滤器用于实现OAuth2.0资源服务:
    • OAuth2AuthenticationProcessingFilter 用来对请求给出的身份令牌解析鉴权。

下面我们将分别创建uaa授权服务(认证服务)和order订单资源服务来演示Spring Security实现OAuth2的认证协议。

认证流程如下:

1、客户端请求UAA授权服务进行认证。

2、认证通过后由UAA颁发令牌。

3、客户端携带令牌Token请求资源服务。

4、资源服务校验令牌的合法性,合法即返回资源信息。

环境搭建

创建授权服务UAA

工程结构如下:

依赖项pom.xml

1
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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.example</groupId>
<artifactId>distributed-security-uaa</artifactId>
<version>1.0-SNAPSHOT</version>

<!-- 指定spring-boot版本 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
</parent>

<dependencies>
<!-- 以下是spring boot依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 以下是spring security依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.8.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.0.10.RELEASE</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
</project>

在resources下创建配置文件application.properties

1
2
3
4
5
6
7
8
9
10
server.port=53020
server.servlet.context-path=/uaa
spring.application.name=uua-server

spring.main.allow-bean-definition-overriding = true

spring.datasource.url = jdbc:mysql://localhost:3306/user_db?useUnicode=true&serverTimezone=CST
spring.datasource.username = root
spring.datasource.password = root
spring.datasource.driver-class-name = com.mysql.jdbc.Driver

创建资源服务Order

该服务用作资源访问,访问该工程资源需要认证通过,主要目的是测试认证授权的功能,并不涉及到真正的订单管理相关逻辑。工程目录如下:

依赖项pom.xml

1
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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.example</groupId>
<artifactId>distributed-security-order</artifactId>
<version>1.0-SNAPSHOT</version>

<!-- 指定spring-boot版本 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
</parent>

<dependencies>
<!-- 以下是spring boot依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 以下是spring security依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.8.RELEASE</version>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.51</version>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.0.10.RELEASE</version>
</dependency>
</dependencies>
</project>

在resources下创建配置文件application.properties

1
2
3
server.port=53021
server.servlet.context-path=/
spring.application.name=order-server

授权服务器配置

可以用@EnableAuthorizationServer注解并继承AuthorizationServerConfigurerAdapter来配置OAuth2.0 授权服务器。 在Config包下创建AuthorizationServerAuthorizationServerConfigurerAdapter要求配置以下几个类,这几个类是由Spring创建的独立的配置对象,它们会被Spring传入AuthorizationServer中进行配置。

1
2
3
4
5
6
7
8
9
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
...
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {}
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {}
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {}
...
}
  • AuthorizationServerSecurityConfigurer:用来配置令牌端点的安全约束。
  • ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),客户端详情信息在这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息。
  • AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token services)。

配置客户端详情服务

ClientDetailsServiceConfigurer能够使用内存或者JDBC来实现客户端详情服务(ClientDetailsService),ClientDetailsService负责查找ClientDetails,而ClientDetails有几个重要的属性如下列表:

  • clientId:(必须的)用来标识客户的Id。
  • secret:(需要值得信任的客户端)客户端安全码,如果有的话。
  • scope:用来限制客户端的访问范围,如果为空(默认)的话,那么客户端拥有全部的访问范围。
  • authorizedGrantTypes:此客户端可以使用的授权类型(亦即OAuth2的四种授权模式),默认为空。
  • authorities:此客户端可以使用的权限(基于Spring Security authorities)。

客户端详情(Client Details)能够在应用程序运行的时候进行更新,可以通过访问底层的存储服务(例如将客户端详情存储在一个关系数据库的表中,就可以使用 JdbcClientDetailsService或者通过自己实现ClientRegistrationService接口(同时你也可以实现 ClientDetailsService 接口)来进行管理。 我们暂时使用内存方式存储客户端详情信息,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 自己实现ClientRegistrationService接口
// clients.withClientDetails(clientDetailsService);
// 使用in‐memory存储
clients.inMemory()
.withClient("c1")// client_id
.secret(new BCryptPasswordEncoder().encode("secret")) //客户端密钥
.resourceIds("res1") //资源列表
.authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型,四种授权类型加一个刷新token
.scopes("all")// 允许的授权范围
.autoApprove(false) //false 会跳转到授权确认页面,用户需要选择同意或者拒绝授权;true 不经用户同意直接确认
.redirectUris("http://www.baidu.com");//加上验证回调地址
}

配置令牌访问端点

配置令牌服务

AuthorizationServerTokenServices接口定义了一些操作使得你可以对令牌进行一些必要的管理,令牌可以被用来加载身份信息,里面包含了这个令牌的相关权限。

自己可以创建AuthorizationServerTokenServices这个接口的实现,则需要继承DefaultTokenServices这个类,里面包含了一些有用实现,你可以使用它来修改令牌的格式和令牌的存储。默认的,当它尝试创建一个令牌的时候,是使用随机值来进行填充的,除了持久化令牌是委托一个TokenStore接口来实现以外,这个类几乎帮你做了所有的事情。并且TokenStore这个接口有一个默认的实现,它就是InMemoryTokenStore,如其命名,所有的令牌是被保存在了内存中。除了使用这个类以外,你还可以使用一些其他的预定义实现,下面有几个版本,它们都实现了TokenStore接口:

  • InMemoryTokenStore:这个版本的实现是被默认采用的,它可以完美的工作在单服务器上(即访问并发量压力不大的情况下,并且它在失败的时候不会进行备份),大多数的项目都可以使用这个版本的实现来进行尝试,可以在开发的时候使用它来进行管理,因为不会被保存到磁盘中,所以更易于调试。
  • JdbcTokenStore:这是一个基于JDBC的实现版本,令牌会被保存进关系型数据库。使用这个版本的实现时,可以在不同的服务器之间共享令牌信息,使用这个版本的时候需要注意把”spring-jdbc”这个依赖加入到classpath当中。
  • JwtTokenStore:这个版本的全称是JSON Web Token(JWT),它可以把令牌相关的数据进行编码(因此对于后端服务来说,它不需要进行存储,这将是一个重大优势),但是它有一个缺点,那就是撤销一个已经授权令牌将会非常困难,所以它通常用来处理一个生命周期较短的令牌以及撤销刷新令牌refresh_token。另外一个缺点就是如果加入了比较多用户凭证信息,这个令牌占用的空间会比较大。JwtTokenStore 不会保存任何数据,但是它在转换令牌值以及授权信息方面与 DefaultTokenServices 所扮演的角色是一样的。

定义TokenConfig,在config包下定义TokenConfig,暂时先使用InMemoryTokenStore,生成一个普通的令牌:

1
2
3
4
5
6
7
@Configuration
public class TokenConfig {
@Bean
public TokenStore tokenStore() {
return new InMemoryTokenStore();
}
}

AuthorizationServer中定义AuthorizationServerTokenServices

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Autowired
private TokenStore tokenStore;

@Autowired
private ClientDetailsService clientDetailsService; //上一步配置的客户端详情服务

@Bean
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
service.setClientDetailsService(clientDetailsService); // 客户端信息服务
service.setSupportRefreshToken(true); // 是否产生刷新令牌
service.setTokenStore(tokenStore); // 令牌存储策略
service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
return service;
}

配置授权类型(Grant Types)

AuthorizationServerEndpointsConfigurer通过设定以下属性决定支持的授权类型(Grant Types):

  • authenticationManager:认证管理器,当选择了资源所有者密码(password)授权类型的时候,需设置这个属性注入一个 AuthenticationManager对象。
  • userDetailsService:如果设置了这个属性的话,那说明你有一个自己的 UserDetailsService 接口的实现, 或者你可以把这个东西设置到全局域上面去(例如 GlobalAuthenticationManagerConfigurer 这个配置对象),当你设置了这个之后,那么refresh_token即刷新令牌授权类型模式的流程中就会包含一个检查,用来确保这个账号是否仍然有效,例如你禁用了这个账户的话。
  • authorizationCodeServices:这个属性是用来设置授权码服务的(即 AuthorizationCodeServices 的实例对象),主要用于authorization_code授权码类型模式。
  • implicitGrantService:这个属性用于设置隐式授权模式,用来管理隐式授权模式的状态。
  • tokenGranter:当设置了这个东西(即 TokenGranter 接口实现),那么授权将会完全交由用户掌控,并且会忽略掉上面的这几个属性,这个属性一般是用作拓展用途的,即标准的四种授权模式已经满足不了需求的时候,才会考虑使用这个。

配置授权端点的URL(Endpoint URL)

AuthorizationServerEndpointsConfigurer这个配置对象有一个叫做pathMapping()的方法用来配置端点URL链接,它有两个参数:

  1. 第一个参数:String 类型的,这个端点URL的默认链接。
  2. 第二个参数:String 类型的,要进行替代的URL链接。

以上的参数都将以 “/“ 字符为开始的字符串,框架的默认URL链接如下列表,可以作为这个 pathMapping() 方法的第一个参数:

  • /oauth/authorize:授权端点。
  • /oauth/token:令牌端点。
  • /oauth/confirm_access:用户确认授权提交端点。
  • /oauth/error:授权服务错误信息端点。
  • /oauth/check_token:用于资源服务访问的令牌解析端点。
  • /oauth/token_key:提供公有密匙的端点,如果你使用JWT令牌的话。

需要注意的是授权端点这个URL应该被Spring Security保护起来只供授权用户访问。

AuthorizationServer配置令牌访问端点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Autowired
private AuthorizationCodeServices authorizationCodeServices;

@Autowired
private AuthenticationManager authenticationManager;

//设置授权码模式的授权码如何存取,暂时采用内存方式
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new InMemoryAuthorizationCodeServices();
}

// 令牌访问端点
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager) // 密码模式需要
.authorizationCodeServices(authorizationCodeServices) //授权码模式需要
.tokenServices(tokenService()) //上一步配置的令牌管理服务
.allowedTokenEndpointRequestMethods(HttpMethod.POST); //允许post提交
}

配置令牌端点安全约束

AuthorizationServerSecurityConfigurer用来配置令牌端点(Token Endpoint)的安全约束,在AuthorizationServer中配置如下:

1
2
3
4
5
6
7
8
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
.tokenKeyAccess("permitAll()") // oauth/token_key公开,该路径用于当使用JwtToken令牌且使用非对称加密时,资源服务获取公钥
.checkTokenAccess("permitAll()") // oauth/check_token公开,该路径用于检测令牌
.allowFormAuthenticationForClients() //允许表单认证(申请令牌)
;
}

总结

授权服务配置分成三大块:

  1. 既然要完成认证,它首先得知道客户端信息从哪儿读取,因此要进行客户端详情配置。
  2. 既然要颁发token,那必须得定义token的相关endpoint,以及token如何存取,以及客户端支持哪些类型的token。
  3. 既然暴露除了一些endpoint,那对这些endpoint可以定义一些安全上的约束等。

Web安全配置

在config包中创建WebSecurityConfig类:

1
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
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

//认证管理器
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

//安全拦截机制(最重要)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/r/r1").hasAnyAuthority("p1")
.antMatchers("/login*").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
;
}
}

四种授权模式

授权码模式

(1)资源拥有者打开客户端,客户端要求资源拥有者给予授权,它将浏览器被重定向到授权服务器,重定向时会附加客户端的身份信息。如: /uaa/oauth/authorize?client_id=c1&response_type=code&scope=all&redirect_uri=http://www.baidu.com

参数列表如下:

  • client_id:客户端准入标识。
  • response_type:授权码模式固定为code。
  • scope:客户端权限。
  • redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。

(2)浏览器出现向授权服务器授权页面,之后将用户同意授权。

(3)授权服务器将授权码(AuthorizationCode)转经浏览器发送给client(通过redirect_uri)。

(4)客户端拿着授权码向授权服务器索要访问access_token,请求如下:/uaa/oauth/token? client_id=c1&client_secret=secret&grant_type=authorization_code&code=5PgfcD&redirect_uri=http://www.baidu.com

参数列表如下:

  • client_id:客户端准入标识。
  • client_secret:客户端秘钥。
  • grant_type:授权类型,填写authorization_code,表示授权码模式。
  • code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
  • redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。

(5)授权服务器返回令牌(access_token)

这种模式是四种模式中最安全的一种模式。一般用于client是Web服务器端应用或第三方的原生App调用资源服务的时候。因为在这种模式中access_token不会经过浏览器或移动端的App,而是直接从服务端去交换,这样就最大限度的减小了令牌泄漏的风险。

测试

浏览器访问认证页面http://localhost:53020/uaa/oauth/authorize?client_id=c1&response_type=code&scope=all&redirect_uri=http://www.baidu.com

然后输入模拟的账号和密码点登陆之后进入授权页面:

确认授权后,浏览器会重定向到指定路径(redirect_uri)并附加验证码?code=DB2mFj(每次不一样),最后使用该验证码获取token。

简化模式

(1)资源拥有者打开客户端,客户端要求资源拥有者给予授权,它将浏览器被重定向到授权服务器,重定向时会附加客户端的身份信息。如:/uaa/oauth/authorize?client_id=c1&response_type=token&scope=all&redirect_uri=http://www.baidu.com参数描述同授权码模式 ,注意response_type=token,说明是简化模式。

(2)浏览器出现向授权服务器授权页面,之后将用户同意授权。

(3)授权服务器将授权码将令牌(access_token)以Hash的形式存放在重定向uri的fargment中发送给浏览器。

注:fragment 主要是用来标识 URI 所标识资源里的某个资源,在 URI 的末尾通过 (#)作为 fragment 的开头, 其中 # 不属于 fragment 的值。如https://domain/index#L18这个 URI中L18就是 fragment 的值。js通过响应浏览器地址栏变化的方式获取到fragment 。一般来说,简化模式用于没有服务器端的第三方单页面应用,因为没有服务器端就无法接收授权码。

测试

浏览器访问认证页面:http://localhost:53020/uaa/oauth/authorize?client_id=c1&response_type=token&scope=all&redirect_uri=http://www.baidu.com (同授权码模式)

然后输入模拟的账号和密码点登陆之后进入授权页面(同授权码模式)

确认授权后,浏览器会重定向到指定路径(oauth_client_details表中的web_server_redirect_uri)并以Hash的形式存放在重定向uri的fargment中,如:http://aa.bb.cc/receive#access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZW5hbn...

密码模式

(1)资源拥有者将用户名、密码发送给客户端。

(2)客户端拿着资源拥有者的用户名、密码向授权服务器请求令牌(access_token),请求如下:/uaa/oauth/token? client_id=c1&client_secret=secret&grant_type=password&username=zhangsan&password=123

参数列表如下:

  • client_id:客户端准入标识。
  • client_secret:客户端秘钥。
  • grant_type:授权类型,填写password表示密码模式。
  • username:资源拥有者用户名。
  • password:资源拥有者密码。

(3)授权服务器将令牌(access_token)发送给client 这种模式十分简单,但是却意味着直接将用户敏感信息泄漏给了client,因此这就说明这种模式只能用于client是我们自己开发的情况下。因此密码模式一般用于我们自己开发的,第一方原生App或第一方单页面应用。

测试

客户端模式

(1)客户端向授权服务器发送自己的身份信息,并请求令牌(access_token)

(2)确认客户端身份无误后,将令牌(access_token)发送给client,请求如下:/uaa/oauth/token?client_id=c1&client_secret=secret&grant_type=client_credentials

参数列表如下:

  • client_id:客户端准入标识。
  • client_secret:客户端秘钥。
  • grant_type:授权类型,填写client_credentials表示客户端模式。

这种模式是最方便但最不安全的模式。因此这就要求我们对client完全的信任,而client本身也是安全的。因此这种模式一般用来提供给我们完全信任的服务器端服务。比如,合作方系统对接,拉取一组用户信息。

测试

验证资源服务

资源服务器配置

@EnableResourceServer注解到一个@Configuration配置类上,并且必须使用ResourceServerConfigurer这个配置对象来进行配置(可以选择继承自ResourceServerConfigurerAdapter然后覆写其中的方法,参数就是这个对象的实例),下面是一些可以配置的属性:

ResourceServerSecurityConfigurer中主要包括:

  • tokenServices:ResourceServerTokenServices 类的实例,用来实现令牌服务。
  • tokenStore:TokenStore类的实例,指定令牌如何访问,与tokenServices配置可选
  • resourceId:这个资源服务的ID,这个属性是可选的,但是推荐设置并在授权服务中进行验证。
  • 其他的拓展属性例如 tokenExtractor 令牌提取器用来提取请求中的令牌。

HttpSecurity配置这个与Spring Security类似:

  • 请求匹配器,用来设置需要进行保护的资源路径,默认的情况下是保护资源服务的全部路径。
  • 通过http.authorizeRequests()来设置受保护资源的访问规则。
  • 其他的自定义权限保护规则通过 HttpSecurity 来进行配置。

@EnableResourceServer注解自动增加了一个类型为 OAuth2AuthenticationProcessingFilter 的过滤器链。

编写ResouceServerConfig:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
@EnableResourceServer
public class ResouceServerConfig extends ResourceServerConfigurerAdapter {

public static final String RESOURCE_ID = "res1";

@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID)//资源 id
.tokenServices(tokenService())//验证令牌的服务
.stateless(true);
}

@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/**").access("#oauth2.hasScope('all')")
.and().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}

验证token

ResourceServerTokenServices是组成授权服务的另一半,如果授权服务和资源服务在同一个应用程序上的话,可以使用 DefaultTokenServices,这样的话就不用考虑关于实现所有必要的接口的一致性问题。如果资源服务器是分离开的,那么就必须要确保能够有匹配授权服务提供的 ResourceServerTokenServices,它知道如何对令牌进行解码。

令牌解析方法: 使用 DefaultTokenServices 在资源服务器本地配置令牌存储、解码、解析方式。使用RemoteTokenServices 资源服务器通过 HTTP 请求来解码令牌,每次都请求授权服务器端点/oauth/check_token。使用授权服务的/oauth/check_token端点需要在授权服务将这个端点暴露出去,以便资源服务可以进行访问。我们已经在授权服务中配置了/oauth/check_token/oauth/token_key这两个端点:

1
2
3
4
5
6
7
8
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
.tokenKeyAccess("permitAll()") //oauth/token_key是公开
.checkTokenAccess("permitAll()") //oauth/check_token公开
.allowFormAuthenticationForClients() //表单认证(申请令牌)
;
}

在资源服务配置RemoteTokenServices ,在ResouceServerConfig中增加如下配置:

1
2
3
4
5
6
7
8
9
10
//资源服务令牌解析服务
@Bean
public ResourceServerTokenServices tokenService() {
//使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
RemoteTokenServices service = new RemoteTokenServices();
service.setCheckTokenEndpointUrl("http://localhost:53020/uaa/oauth/check_token");
service.setClientId("c1");
service.setClientSecret("secret");
return service;
}

编写资源

在controller包下编写OrderController,此controller表示订单资源的访问类:

1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping(value = "/order")
public class OrderController {
@GetMapping(value = "/r1")
@PreAuthorize("hasAuthority('p1')")//拥有p1权限方可访问此url
public String r1() {
return "访问资源1";
}
}

添加安全访问控制

其他自定义的权限保护规则配置在WebSecurityConfig中,因为我们已经在前面的资源服务配置中设定了访问控制,所以这里是可选的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

//安全拦截机制(最重要)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/r/**").authenticated()//所有/r/**的请求必须认证通过
.anyRequest().permitAll()//除了/r/**,其它的请求可以访问
;
}
}

测试

申请令牌,这里我们使用密码方式

请求资源,按照oauth2.0协议要求,请求资源需要在请求头携带token信息,参数名称为:Authorization,值为:Bearer token值

注意:因为我们的资源r1规定了需要有p1权限才能访问,如果我们采用客户端的授权模式将不包含用户的身份信息,也就是说通过客户端授权模式获取的token无法访问资源r1

1
2
3
4
{
"error": "access_denied",
"error_description": "不允许访问"
}

自定义错误返回

我们可以自定义token失效禁止访问的返回信息,在handle包中分别添加2个处理类:

1
2
3
4
5
6
7
8
9
10
11
12
//权限不够,禁止访问的自定义处理类
public class SimpleAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", "00002");
jsonObject.put("text", "拒绝访问");
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().append(jsonObject.toString());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
//token无效的自定义处理类
public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", "00001");
jsonObject.put("text", "身份失效");
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().append(jsonObject.toString());
}
}

然后在资源服务配置ResouceServerConfig中设置自定义错误处理:

1
2
3
4
5
6
7
8
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID)//资源 id
.tokenServices(tokenService())//验证令牌的服务
.stateless(true);
resources.authenticationEntryPoint(new SimpleAuthenticationEntryPoint()); //token失效自定义处理
resources.accessDeniedHandler(new SimpleAccessDeniedHandler()); //拒绝访问自定义处理
}

JWT令牌

JWT介绍

通过上边的测试我们发现,当资源服务和授权服务不在一起时资源服务使用RemoteTokenServices远程请求授权服务验证token,如果访问量较大将会影响系统的性能。

令牌采用JWT格式即可解决上边的问题,用户认证通过会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。

JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。

官网:https://jwt.io/

标准:https://tools.ietf.org/html/rfc7519

JWT令牌的优点

  • jwt基于json,非常方便解析。
  • 可以在令牌中自定义丰富的内容,易扩展。
  • 通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
  • 资源服务使用JWT可不依赖认证服务即可完成授权。

缺点

  • JWT令牌较长,占存储空间比较大。

JWT令牌结构

JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz

  • Header:头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA) ,例如:

    1
    { "alg": "HS256", "typ": "JWT" }

    将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。

  • Payload:第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的现成字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。

    1
    { "sub": "1234567890", "name": "456", "admin": true }
  • Signature:第三部分是签名,此部分用于防止jwt内容被篡改。这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明签名算法进行签名。

    1
    HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
    • base64UrlEncode(header):jwt令牌的第一部分。
    • base64UrlEncode(payload):jwt令牌的第二部分。
    • secret:签名所使用的密钥。

授权服务配置JWT令牌

在授权服务器uaa中添加jwt依赖:

1
2
3
4
5
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.0.10.RELEASE</version>
</dependency>

权服务器uaa中配置jwt令牌服务,即可实现生成jwt格式的令牌。修改TokenConfig:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class TokenConfig {
private String SIGNING_KEY = "uaa123";

@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}

@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGNING_KEY); //对称秘钥,资源服务器使用该秘钥来验证
return converter;
}
}

定义JWT令牌服务,修改uaa的授权服务AuthorizationServertokenService()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Autowired
private JwtAccessTokenConverter accessTokenConverter;

@Bean
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
service.setClientDetailsService(clientDetailsService);
service.setSupportRefreshToken(true);
service.setTokenStore(tokenStore);

//令牌增强
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter));
service.setTokenEnhancer(tokenEnhancerChain);

service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
return service;
}

可以看到,再次采用密码授权模式调用获取token接口,已经返回了jwt格式的令牌:

资源服务校验JWT令牌

资源服务需要和授权服务拥有一致的签字、令牌服务等,先为资源服务order创建一个和授权服务uaa一模一样的TokenConfig类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class TokenConfig {
private String SIGNING_KEY = "uaa123";

@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}

@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGNING_KEY); //对称秘钥,资源服务器使用该秘钥来验证
return converter;
}
}

屏蔽资源服务原来的令牌服务类tokenService(),然后为其注入一个tokenStore:

1
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
@Configuration
@EnableResourceServer
public class ResouceServerConfig extends ResourceServerConfigurerAdapter {

public static final String RESOURCE_ID = "res1";

@Autowired
TokenStore tokenStore;

@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID)//资源 id
//.tokenServices(tokenService())//验证令牌的服务
.tokenStore(tokenStore)
.stateless(true);
resources.authenticationEntryPoint(new SimpleAuthenticationEntryPoint());
resources.accessDeniedHandler(new SimpleAccessDeniedHandler());
}

@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/**").access("#oauth2.hasScope('all')")
.and().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}

//资源服务令牌解析服务
// @Bean
// public ResourceServerTokenServices tokenService() {
// //使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
// RemoteTokenServices service = new RemoteTokenServices();
// service.setCheckTokenEndpointUrl("http://localhost:53020/uaa/oauth/check_token");
// service.setClientId("c1");
// service.setClientSecret("secret");
// return service;
// }
}

不要忘记和uaa服务一样添加jwt依赖。

使用令牌请求资源:

令牌申请成功后,还可以使用/uaa/oauth/check_token校验令牌的有效性,并查询令牌的内容:

数据库动态配置

目前为止客户端信息和授权码仍然存储在内存中,生产环境中通常会存储在数据库中,我们将分别创建客户端信息表oauth_client_details和授权码模式授权码存储表oauth_code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DROP TABLE IF EXISTS `oauth_client_details`; 
CREATE TABLE `oauth_client_details` (
`client_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '客户端标识',
`resource_ids` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '接入资源列表',
`client_secret` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '客户端秘钥',
`scope` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`authorized_grant_types` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`web_server_redirect_uri` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`authorities` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`access_token_validity` int(11) NULL DEFAULT NULL,
`refresh_token_validity` int(11) NULL DEFAULT NULL,
`additional_information` longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL,
`create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0),
`archived` tinyint(4) NULL DEFAULT NULL,
`trusted` tinyint(4) NULL DEFAULT NULL,
`autoapprove` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '接入客户端信息' ROW_FORMAT = Dynamic;

INSERT INTO `oauth_client_details` VALUES ('c1', 'res1', '$2a$10$NlBC84MVb7F95EXYTXwLneXgCca6/GipyWR5NHm8K0203bSQMLpvm', 'ROLE_ADMIN,ROLE_USER,ROLE_API',
'client_credentials,password,authorization_code,implicit,refresh_token', 'http://www.baidu.com', NULL, 7200, 259200, NULL, '2019‐09‐09 16:04:28', 0, 0, 'false');
INSERT INTO `oauth_client_details` VALUES ('c2', 'res2', '$2a$10$NlBC84MVb7F95EXYTXwLneXgCca6/GipyWR5NHm8K0203bSQMLpvm', 'ROLE_API',
'client_credentials,password,authorization_code,implicit,refresh_token', 'http://www.baidu.com', NULL, 31536000, 2592000, NULL, '2019‐09‐09 21:48:51', 0, 0, 'false');
1
2
3
4
5
6
7
DROP TABLE IF EXISTS `oauth_code`; 
CREATE TABLE `oauth_code` (
`create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`authentication` blob NULL,
INDEX `code_index`(`code`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

修改AuthorizationServer,ClientDetailsService和AuthorizationCodeServices改为从数据库读取数据:

1
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
90
91
92
93
94
95
96
97
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {

@Autowired
private TokenStore tokenStore;

@Autowired
private ClientDetailsService clientDetailsService;

@Autowired
private AuthorizationCodeServices authorizationCodeServices;

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private JwtAccessTokenConverter accessTokenConverter;

// 2.配置令牌服务(token services)
@Bean
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
service.setClientDetailsService(clientDetailsService);
service.setSupportRefreshToken(true);
service.setTokenStore(tokenStore);

//令牌增强
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter));
service.setTokenEnhancer(tokenEnhancerChain);

service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
return service;
}

//设置授权码模式的授权码如何存取,暂时采用内存方式
// @Bean
// public AuthorizationCodeServices authorizationCodeServices() {
// return new InMemoryAuthorizationCodeServices();
// }

@Bean
public AuthorizationCodeServices authorizationCodeServices(DataSource dataSource) {
return new JdbcAuthorizationCodeServices(dataSource);
}


// 4.配置令牌端点(Token Endpoint)的安全约束
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
.tokenKeyAccess("permitAll()") //oauth/token_key是公开
.checkTokenAccess("permitAll()") //oauth/check_token公开
.allowFormAuthenticationForClients() //表单认证(申请令牌)
;
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public ClientDetailsService clientDetailsService(DataSource dataSource) {
ClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
((JdbcClientDetailsService) clientDetailsService).setPasswordEncoder(passwordEncoder());
return clientDetailsService;
}

// 1.客户端详情相关配置
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 自己实现ClientRegistrationService接口
clients.withClientDetails(clientDetailsService);
// 使用in‐memory存储
// clients.inMemory()
// .withClient("c1")// client_id
// .secret(new BCryptPasswordEncoder().encode("secret"))
// .resourceIds("res1")
// .authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型 authorization_code,password,refresh_token,implicit,client_credentials
// .scopes("all")// 允许的授权范围
// .autoApprove(false) //加上验证回调地址
// .redirectUris("http://www.baidu.com");
}

// 3.配置令牌(token)的访问端点
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.authorizationCodeServices(authorizationCodeServices)
.tokenServices(tokenService())
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}
}

测试客户端:使用密码模式申请令牌,客户端信息需要和数据库中的信息一致。

测试授权码模式:生成的授权存储到数据库中。

示例源码:https://github.com/Mcdull0921/security-spring-boot