Spring Security oAuth2(二)

《Spring Security oAuth2(一)》 中主要是oAuth的一些基本概念与四种认证方式,现在就正式开始完成几个主要示例,快速上手Spring提供的Spring Security oAuth2。网上很多示例教程都是使用 JWT(JSON Web Tokens)来管理Token,于是很多人认为oAuth2就应该用JWT来管理Token,其实这是非常错误的想法,后面我会专门就使用 JWT 这个问题进行讨论,看看 JWT 的最佳使用场景。

创建oAuth2 示例工程

新建名为spring-security-oauth2的Maven工程,pom.xml(spring-security-oauth2)如下:

 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0"
 3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
 5    <modelVersion>4.0.0</modelVersion>
 6
 7    <parent>
 8        <groupId>org.springframework.boot</groupId>
 9        <artifactId>spring-boot-starter-parent</artifactId>
10        <version>2.3.10.RELEASE</version>
11        <relativePath/> <!-- lookup parent from repository -->
12    </parent>
13
14    <groupId>cn.tim</groupId>
15    <artifactId>spring-security-oauth2</artifactId>
16    <packaging>pom</packaging>
17    <version>1.0-SNAPSHOT</version>
18
19    <modules>
20        <module>spring-security-oauth2-server</module>
21        <module>spring-security-oauth2-dependencies</module>
22    </modules>
23
24    <properties>
25        <maven.compiler.source>8</maven.compiler.source>
26        <maven.compiler.target>8</maven.compiler.target>
27    </properties>
28
29    <dependencyManagement>
30        <dependencies>
31            <dependency>
32                <groupId>cn.tim</groupId>
33                <artifactId>spring-security-oauth2-dependencies</artifactId>
34                <version>1.0.0-SNAPSHOT</version>
35                <type>pom</type>
36                <scope>import</scope>
37            </dependency>
38        </dependencies>
39    </dependencyManagement>
40
41    <build>
42        <plugins>
43            <plugin>
44                <groupId>org.springframework.boot</groupId>
45                <artifactId>spring-boot-maven-plugin</artifactId>
46            </plugin>
47        </plugins>
48    </build>
49</project>

其中有两个模块,一个是 spring-security-oauth2-dependencies,另一个是 spring-security-oauth2-server。在spring-security-oauth2项目中创建名为 spring-security-oauth2-dependencies 的 Maven 子 Module,作为统一的依赖管理模块,其pom.xml文件如下:

 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0"
 3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
 5    <modelVersion>4.0.0</modelVersion>
 6
 7    <groupId>cn.tim</groupId>
 8    <artifactId>spring-security-oauth2-dependencies</artifactId>
 9    <version>1.0.0-SNAPSHOT</version>
10    <packaging>pom</packaging>
11    <url>https://zouchanglin.cn</url>
12
13    <properties>
14        <maven.compiler.source>8</maven.compiler.source>
15        <maven.compiler.target>8</maven.compiler.target>
16        <spring-cloud.version>Hoxton.RELEASE</spring-cloud.version>
17    </properties>
18    <dependencyManagement>
19        <dependencies>
20            <dependency>
21                <groupId>org.springframework.cloud</groupId>
22                <artifactId>spring-cloud-dependencies</artifactId>
23                <version>${spring-cloud.version}</version>
24                <type>pom</type>
25                <scope>import</scope>
26            </dependency>
27        </dependencies>
28    </dependencyManagement>
29
30    <pluginRepositories>
31        <pluginRepository>
32            <id>spring-milestone</id>
33            <name>Spring Milestone</name>
34            <url>https://repo.spring.io/milestone</url>
35            <snapshots>
36                <enabled>false</enabled>
37            </snapshots>
38        </pluginRepository>
39        <pluginRepository>
40            <id>spring-snapshot</id>
41            <name>Spring Snapshot</name>
42            <url>https://repo.spring.io/snapshot</url>
43            <snapshots>
44                <enabled>true</enabled>
45            </snapshots>
46        </pluginRepository>
47    </pluginRepositories>
48</project>

接下来创建另一个子 Module,即 spring-security-oauth2-server,这个模块作为认证/授权服务器模块,对应的pom.xml 如下:

 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0"
 3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
 5    <parent>
 6        <artifactId>spring-security-oauth2</artifactId>
 7        <groupId>cn.tim</groupId>
 8        <version>1.0-SNAPSHOT</version>
 9    </parent>
10
11    <modelVersion>4.0.0</modelVersion>
12
13    <artifactId>spring-security-oauth2-server</artifactId>
14
15    <properties>
16        <maven.compiler.source>8</maven.compiler.source>
17        <maven.compiler.target>8</maven.compiler.target>
18    </properties>
19
20    <dependencies>
21        <dependency>
22            <groupId>org.springframework.boot</groupId>
23            <artifactId>spring-boot-starter-web</artifactId>
24        </dependency>
25
26        <dependency>
27            <groupId>org.springframework.cloud</groupId>
28            <artifactId>spring-cloud-starter-oauth2</artifactId>
29        </dependency>
30
31        <dependency>
32            <groupId>org.springframework.boot</groupId>
33            <artifactId>spring-boot-starter-test</artifactId>
34        </dependency>
35    </dependencies>
36
37    <build>
38        <plugins>
39            <plugin>
40                <groupId>org.springframework.boot</groupId>
41                <artifactId>spring-boot-maven-plugin</artifactId>
42                <configuration>
43                    <mainClass>cn.tim.security.server.OAuth2ServerApplication</mainClass>
44                </configuration>
45            </plugin>
46        </plugins>
47    </build>
48</project>

工程到这里也就搭建完毕了,剩下的内容就是编写代码了。

基于内存存储令牌

基于内存存储令牌的模式用于演示最基本的操作,这样可以快速地理解 oAuth2 认证服务器中 “认证”、“授权”、“访问令牌” 的基本概念,还是下面这张图: step1: 请求认证服务获取授权码,请求地址:

1GET http://localhost:8080/oauth/authorize?client_id=client&response_type=code

携带了client_id以及认证类型response_type等参数。

step2: 认证通过,回调注册的URL,携带参数code

1GET http://emaple.com/xxx?code=xxxxx

step3: 请求认证服务器获取令牌

1POST http://yourClientId:yourSecret@localhost:8080/oauth/token

携带参数为grant_type:authorization_code,code:xxxxx(code就是step2中的code)

step4: 认证服务器返回令牌

1{
2    "access_token": "5cb673e1-d5c1-4887-b766-0975b29ab74b",
3    "token_type": "bearer",
4    "expires_in": 43199,
5    "scope": "app"
6}

步骤就是上面的步骤,现在开始编写认证服务器的配置代码。创建一个类继承 AuthorizationServerConfigurerAdapter 并添加相关注解

 1package cn.tim.security.server.config;
 2
 3import org.springframework.beans.factory.annotation.Autowired;
 4import org.springframework.context.annotation.Configuration;
 5import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 6import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
 7import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
 8import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
 9
10@Configuration
11@EnableAuthorizationServer
12public class AuthenticationServerConfiguration extends AuthorizationServerConfigurerAdapter {
13
14    @Autowired
15    private BCryptPasswordEncoder passwordEncoder;
16
17    // 1、基于内存存储令牌
18    @Override
19    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
20        // 配置客户端
21        clients
22                // 使用内存设置
23                .inMemory()
24                // client_id
25                .withClient("client")
26                // client_secret,注意这个secret是需要加密的,这里使用默认编码器
27                .secret(passwordEncoder.encode("secret"))
28                // 授权类型
29                .authorizedGrantTypes("authorization_code")
30                // 授权范围
31                .scopes("app")
32                // 注册回调地址
33                .redirectUris("https://example.com");
34    }
35}

然后是服务器安全配置,创建一个类继承 WebSecurityConfigurerAdapter 并添加相关注解:

 1package cn.tim.security.server.config;
 2
 3import org.springframework.context.annotation.Bean;
 4import org.springframework.context.annotation.Configuration;
 5import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
 6import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
 7import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 8import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 9import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
10
11
12@Configuration
13@EnableWebSecurity
14@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
15public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
16
17    @Bean
18    public BCryptPasswordEncoder passwordEncoder(){
19        return new BCryptPasswordEncoder();
20    }
21
22    @Override
23    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
24        auth.inMemoryAuthentication()
25                .withUser("admin").password(passwordEncoder().encode("123456")).roles("ADMIN")
26                .and()
27                .withUser("user").password(passwordEncoder().encode("123456")).roles("USER");
28    }
29}

整个项目结构如下: 现在开始访问获取授权码,打开浏览器,输入地址:

1http://localhost:8080/oauth/authorize?client_id=client&response_type=code

会跳转到登录页面: 验证成功后会询问用户是否授权客户端 选择授权后会跳转到预先设定的地址,浏览器地址上还会包含一个授权码(code=j4jmTM),浏览器地址栏会显示如下地址:

1https://example.com/?code=j4jmTM

有了这个授权码就可以获取访问令牌了,现在我们通过授权码向服务器申请令牌,通过curl的方式访问:

1curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d 'grant_type=authorization_code&code=j4jmTM' "http://client:secret@localhost:8080/oauth/token"

得到的结果如下:

1{
2    "access_token":"b15c7d2d-b8ea-41e7-ac09-c7fd1517114b",
3    "token_type":"bearer",
4    "expires_in":43199,
5    "scope":"app"
6}

现在用PostMan请求试试: 现在重新换个请求码,直接用PostMan请求access_token:

通过 GET 请求访问认证服务器获取授权码 -> 端点:/oauth/authorize

通过 POST 请求利用授权码访问认证服务器获取令牌 -> 端点:/oauth/token

附:默认的端点 URL如下:

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

基于JDBC存储令牌

基于JDBC存储令牌其实与内存是类似的,只不过这次客户端信息就不是写在代码里了,而是存在于数据库中。主要步骤如下: 1、初始化 oAuth2 相关表 2、在数据库中配置客户端 3、配置认证服务器 - 配置数据源:DataSource - 配置令牌存储方式:TokenStore -> JdbcTokenStore - 配置客户端读取方式:ClientDetailsService -> JdbcClientDetailsService - 配置服务端点信息:AuthorizationServerEndpointsConfigurer - tokenStore:设置令牌存储方式 - 配置客户端信息:ClientDetailsServiceConfigurer - withClientDetails:设置客户端配置读取方式 4、配置 Web 安全 - 配置密码加密方式:BCryptPasswordEncoder - 配置认证信息:AuthenticationManagerBuilder 5、通过 GET 请求访问认证服务器获取授权码 - 端点:/oauth/authorize 6、通过 POST 请求利用授权码访问认证服务器获取令牌 - 端点:/oauth/token

使用官方提供的建表脚本初始化 oAuth2 相关表,地址如下:

1https://github.com/spring-projects/spring-security-oauth/blob/master/spring-security-oauth2/src/test/resources/schema.sql

由于我们使用的是 MySQL 数据库,默认建表语句中主键为 VARCHAR(256),这超过了最大的主键长度,请手动修改为 128,并用 BLOB 替换语句中的 LONGVARBINARY 类型,修改后的建表脚本如下:

 1create database oauth2 charset utf8;
 2
 3use oauth2;
 4
 5-- used in tests that use HSQL
 6create table oauth_client_details
 7(
 8    client_id               VARCHAR(256) PRIMARY KEY,
 9    resource_ids            VARCHAR(256),
10    client_secret           VARCHAR(256),
11    scope                   VARCHAR(256),
12    authorized_grant_types  VARCHAR(256),
13    web_server_redirect_uri VARCHAR(256),
14    authorities             VARCHAR(256),
15    access_token_validity   INTEGER,
16    refresh_token_validity  INTEGER,
17    additional_information  VARCHAR(4096),
18    autoapprove             VARCHAR(256)
19);
20
21create table oauth_client_token
22(
23    token_id          VARCHAR(256),
24    token             BLOB,
25    authentication_id VARCHAR(256) PRIMARY KEY,
26    user_name         VARCHAR(256),
27    client_id         VARCHAR(256)
28);
29
30create table oauth_access_token
31(
32    token_id          VARCHAR(256),
33    token             BLOB,
34    authentication_id VARCHAR(256) PRIMARY KEY,
35    user_name         VARCHAR(256),
36    client_id         VARCHAR(256),
37    authentication    BLOB,
38    refresh_token     VARCHAR(256)
39);
40
41create table oauth_refresh_token
42(
43    token_id       VARCHAR(256),
44    token          BLOB,
45    authentication BLOB
46);
47
48create table oauth_code
49(
50    code           VARCHAR(256),
51    authentication BLOB
52);
53
54create table oauth_approvals
55(
56    userId         VARCHAR(256),
57    clientId       VARCHAR(256),
58    scope          VARCHAR(256),
59    status         VARCHAR(10),
60    expiresAt      TIMESTAMP,
61    lastModifiedAt TIMESTAMP
62);
63
64
65-- customized oauth_client_details table
66create table ClientDetails
67(
68    appId                  VARCHAR(256) PRIMARY KEY,
69    resourceIds            VARCHAR(256),
70    appSecret              VARCHAR(256),
71    scope                  VARCHAR(256),
72    grantTypes             VARCHAR(256),
73    redirectUrl            VARCHAR(256),
74    authorities            VARCHAR(256),
75    access_token_validity  INTEGER,
76    refresh_token_validity INTEGER,
77    additionalInformation  VARCHAR(4096),
78    autoApproveScopes      VARCHAR(256)
79);

接下来在数据库中配置一条客户端信息,在表 oauth_client_details 中增加一条客户端配置记录,需要设置的字段如下: client_id:客户端标识 client_secret:客户端安全码,此处不能是明文,需要加密 scope:客户端授权范围 authorized_grant_types:客户端授权类型 web_server_redirect_uri:服务器回调地址 client_secret需要使用 BCryptPasswordEncoder 为客户端安全码加密,代码如下:

1System.out.println(new BCryptPasswordEncoder().encode("secret"));

由于使用了 JDBC 存储,需要增加相关依赖,数据库连接池部分弃用 Druid 改为 HikariCP (号称全球最快连接池) 统一依赖管理里面添加依赖:

 1<dependency>
 2    <groupId>com.zaxxer</groupId>
 3    <artifactId>HikariCP</artifactId>
 4    <version>3.4.5</version>
 5</dependency>
 6<dependency>
 7    <groupId>org.springframework.boot</groupId>
 8    <artifactId>spring-boot-starter-jdbc</artifactId>
 9    <exclusions>
10        <!-- 排除 tomcat-jdbc 以使用 HikariCP -->
11        <exclusion>
12            <groupId>org.apache.tomcat</groupId>
13            <artifactId>tomcat-jdbc</artifactId>
14        </exclusion>
15    </exclusions>
16</dependency>
17<dependency>
18    <groupId>mysql</groupId>
19    <artifactId>mysql-connector-java</artifactId>
20    <version>8.0.23</version>
21</dependency>

然后配置认证服务器,创建一个类继承 AuthorizationServerConfigurerAdapter 并添加相关注解:

 1package cn.tim.security.server.config;
 2
 3import org.springframework.boot.context.properties.ConfigurationProperties;
 4import org.springframework.boot.jdbc.DataSourceBuilder;
 5import org.springframework.context.annotation.Bean;
 6import org.springframework.context.annotation.Configuration;
 7import org.springframework.context.annotation.Primary;
 8import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
 9import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
10import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
11import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
12import org.springframework.security.oauth2.provider.ClientDetailsService;
13import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
14import org.springframework.security.oauth2.provider.token.TokenStore;
15import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
16
17import javax.sql.DataSource;
18
19@Configuration
20@EnableAuthorizationServer
21public class AuthenticationServerConfiguration extends AuthorizationServerConfigurerAdapter {
22    
23    // 2、基于JDBC存储令牌
24    @Bean
25    @Primary
26    @ConfigurationProperties(prefix = "spring.datasource")
27    public DataSource dataSource(){
28        // 配置数据源(注意,我使用的是 HikariCP 连接池),以上注解是指定数据源,否则会有冲突
29        return DataSourceBuilder.create().build();
30    }
31
32    @Bean
33    public TokenStore tokenStore(){
34        // 基于 JDBC 实现,令牌保存到数据
35        return new JdbcTokenStore(dataSource());
36    }
37
38    @Bean
39    public ClientDetailsService jdbcClientDetails(){
40        // 基于 JDBC 实现,需要事先在数据库配置客户端信息
41        return new JdbcClientDetailsService(dataSource());
42    }
43
44    @Override
45    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
46        // 设置令牌
47        clients.withClientDetails(jdbcClientDetails());
48    }
49
50    @Override
51    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
52        // 读取客户端配置
53        endpoints.tokenStore(tokenStore());
54    }
55}

然后是服务器安全配置,创建一个类继承 WebSecurityConfigurerAdapter 并添加相关注解,和基于内存存储令牌一模一样,这里就不再贴出代码了。最后配置一下application.yml连接参数:

 1spring:
 2  application:
 3    name: oauth2-server
 4  datasource:
 5    type: com.zaxxer.hikari.HikariDataSource
 6    jdbcUrl: jdbc:mysql://127.0.0.1:3306/oauth2?useUnicode=true&characterEncoding=utf-8&useSSL=false
 7    driver-class-name: com.mysql.cj.jdbc.Driver
 8    username: root
 9    password: 12345678
10    hikari:
11      minimum-idle: 5
12      idle-timeout: 600000
13      maximum-pool-size: 10
14      auto-commit: true
15      pool-name: MyHikariCP
16      max-lifetime: 1800000
17      connection-timeout: 30000
18      connection-test-query: SELECT 1
19server:
20  port: 8080

测试过程和上面一致 操作成功后数据库 oauth_access_token 表中会增加一笔记录,效果图如下:

关于JWT的适用场景

谈论这个问题之前先要搞清楚什么是JWT,这一块可以参考阮一峰老师的博客 《JSON Web Token 入门教程》 。 很多人总是错误的想去比较 cookies 跟 JWT。这种比较是毫无意义的——cookies 是一种存储机制,而 JWT 是一种加密签名的令牌机制。它们之间并不是相互对立的,相反的,它们既可以独立使用还可以配合使用。真正应该比较的是 session 跟 JWT 以及 cookies 跟 Local Storage。

让我们再次回到认证 / 授权这个问题上来:

  • 认证(Authentication):验证目标对象身份。比如,通过用户名和密码登录某个系统就是认证。
  • 授权(Authorization):给予通过验证的目标对象操作权限。

更简单地说:认证解决了「你是谁」的问题,授权解决了「你能做什么」的问题。 HTTP 是无状态的,所以客户端和服务端需要解决的如何让之间的对话变得有状态。例如只有是登陆状态的用户才有权限调用某些接口,那么在用户登陆之后,需要记住该用户是已经登陆的状态,常见的方法是使用 session 机制。

JWT只是把用户信息,过期信息都打包到token里面去了,不需要在服务端存储而已,如何把JWT在服务端存储起来(比如内存和redis)来实现续签,注销等场景,相当于把重复把由web服务器实现的session重新造了一遍轮子而已。多了 JWT 的加解密计算资源不说,还没有session机制更安全。

说到这里我想到一个清晰移动的例子,这就好比你非要拿UDP去实现可靠传输,那么也就意味着需要手动实现 TCP 的特性,那还不如直接使用 TCP,同样的道理如果拿JWT会管理会话,需要手动扩展去解决续签,注销等操作那还不如直接使用 session。

那么 JWT 的最合适的应用场景是什么呢?那就是一次性验证,比如用户注册后需要发一封邮件让其激活账户,通常邮件中需要有一个链接,这个链接需要具备以下的特性:能够标识用户,该链接具有时效性(通常只允许几小时之内激活),不能被篡改以激活其他可能的账户。这种场景就和 JWT 的特性非常贴近,JWT 的 payload 中固定的参数:iss 签发者和 exp 过期时间正是为其做准备的。 JWT 适合做简单的 Restful API 认证,颁发一个固定有效期的 JWT,降低 JWT 暴露的风险,不要使用 JWT 做服务端的状态管理,这样才能体现出 JWT 无状态的优势。

参考资料

《Stop using JWT for sessions》