Spring Security oAuth2(二)

1,166 阅读10分钟

《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)如下:

<?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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.10.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>cn.tim</groupId>
    <artifactId>spring-security-oauth2</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <modules>
        <module>spring-security-oauth2-server</module>
        <module>spring-security-oauth2-dependencies</module>
    </modules>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>cn.tim</groupId>
                <artifactId>spring-security-oauth2-dependencies</artifactId>
                <version>1.0.0-SNAPSHOT</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

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

<?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>cn.tim</groupId>
    <artifactId>spring-security-oauth2-dependencies</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>
    <url>https://zouchanglin.cn</url>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <spring-cloud.version>Hoxton.RELEASE</spring-cloud.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <pluginRepositories>
        <pluginRepository>
            <id>spring-milestone</id>
            <name>Spring Milestone</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </pluginRepository>
        <pluginRepository>
            <id>spring-snapshot</id>
            <name>Spring Snapshot</name>
            <url>https://repo.spring.io/snapshot</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </pluginRepository>
    </pluginRepositories>
</project>

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

<?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">
    <parent>
        <artifactId>spring-security-oauth2</artifactId>
        <groupId>cn.tim</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <modelVersion>4.0.0</modelVersion>

    <artifactId>spring-security-oauth2-server</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <mainClass>cn.tim.security.server.OAuth2ServerApplication</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

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

基于内存存储令牌

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

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

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

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

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

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

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

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

step4: 认证服务器返回令牌

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

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

package cn.tim.security.server.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;

@Configuration
@EnableAuthorizationServer
public class AuthenticationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private BCryptPasswordEncoder passwordEncoder;

    // 1、基于内存存储令牌
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 配置客户端
        clients
                // 使用内存设置
                .inMemory()
                // client_id
                .withClient("client")
                // client_secret,注意这个secret是需要加密的,这里使用默认编码器
                .secret(passwordEncoder.encode("secret"))
                // 授权类型
                .authorizedGrantTypes("authorization_code")
                // 授权范围
                .scopes("app")
                // 注册回调地址
                .redirectUris("https://example.com");
    }
}

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

package cn.tim.security.server.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;@Configuration@EnableWebSecurity@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {    @Bean    public BCryptPasswordEncoder passwordEncoder(){        return new BCryptPasswordEncoder();    }    @Override    protected void configure(AuthenticationManagerBuilder auth) throws Exception {        auth.inMemoryAuthentication()                .withUser("admin").password(passwordEncoder().encode("123456")).roles("ADMIN")                .and()                .withUser("user").password(passwordEncoder().encode("123456")).roles("USER");    }}

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

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

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

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

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

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

得到的结果如下:

{    "access_token":"b15c7d2d-b8ea-41e7-ac09-c7fd1517114b",    "token_type":"bearer",    "expires_in":43199,    "scope":"app"}

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

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

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

附:默认的端点 URL如下:

/oauth/authorize:授权端点/oauth/token:令牌端点/oauth/confirm_access:用户确认授权提交端点/oauth/error:授权服务错误信息端点/oauth/check_token:用于资源服务访问的令牌解析端点/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 相关表,地址如下:

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

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

create database oauth2 charset utf8;use oauth2;-- used in tests that use HSQLcreate table oauth_client_details(    client_id               VARCHAR(256) PRIMARY KEY,    resource_ids            VARCHAR(256),    client_secret           VARCHAR(256),    scope                   VARCHAR(256),    authorized_grant_types  VARCHAR(256),    web_server_redirect_uri VARCHAR(256),    authorities             VARCHAR(256),    access_token_validity   INTEGER,    refresh_token_validity  INTEGER,    additional_information  VARCHAR(4096),    autoapprove             VARCHAR(256));create table oauth_client_token(    token_id          VARCHAR(256),    token             BLOB,    authentication_id VARCHAR(256) PRIMARY KEY,    user_name         VARCHAR(256),    client_id         VARCHAR(256));create table oauth_access_token(    token_id          VARCHAR(256),    token             BLOB,    authentication_id VARCHAR(256) PRIMARY KEY,    user_name         VARCHAR(256),    client_id         VARCHAR(256),    authentication    BLOB,    refresh_token     VARCHAR(256));create table oauth_refresh_token(    token_id       VARCHAR(256),    token          BLOB,    authentication BLOB);create table oauth_code(    code           VARCHAR(256),    authentication BLOB);create table oauth_approvals(    userId         VARCHAR(256),    clientId       VARCHAR(256),    scope          VARCHAR(256),    status         VARCHAR(10),    expiresAt      TIMESTAMP,    lastModifiedAt TIMESTAMP);-- customized oauth_client_details tablecreate table ClientDetails(    appId                  VARCHAR(256) PRIMARY KEY,    resourceIds            VARCHAR(256),    appSecret              VARCHAR(256),    scope                  VARCHAR(256),    grantTypes             VARCHAR(256),    redirectUrl            VARCHAR(256),    authorities            VARCHAR(256),    access_token_validity  INTEGER,    refresh_token_validity INTEGER,    additionalInformation  VARCHAR(4096),    autoApproveScopes      VARCHAR(256));

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

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

img

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

<dependency>    <groupId>com.zaxxer</groupId>    <artifactId>HikariCP</artifactId>    <version>3.4.5</version></dependency><dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-jdbc</artifactId>    <exclusions>        <!-- 排除 tomcat-jdbc 以使用 HikariCP -->        <exclusion>            <groupId>org.apache.tomcat</groupId>            <artifactId>tomcat-jdbc</artifactId>        </exclusion>    </exclusions></dependency><dependency>    <groupId>mysql</groupId>    <artifactId>mysql-connector-java</artifactId>    <version>8.0.23</version></dependency>

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

package cn.tim.security.server.config;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.boot.jdbc.DataSourceBuilder;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.Primary;import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;import org.springframework.security.oauth2.provider.ClientDetailsService;import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;import org.springframework.security.oauth2.provider.token.TokenStore;import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;import javax.sql.DataSource;@Configuration@EnableAuthorizationServerpublic class AuthenticationServerConfiguration extends AuthorizationServerConfigurerAdapter {        // 2、基于JDBC存储令牌    @Bean    @Primary    @ConfigurationProperties(prefix = "spring.datasource")    public DataSource dataSource(){        // 配置数据源(注意,我使用的是 HikariCP 连接池),以上注解是指定数据源,否则会有冲突        return DataSourceBuilder.create().build();    }    @Bean    public TokenStore tokenStore(){        // 基于 JDBC 实现,令牌保存到数据        return new JdbcTokenStore(dataSource());    }    @Bean    public ClientDetailsService jdbcClientDetails(){        // 基于 JDBC 实现,需要事先在数据库配置客户端信息        return new JdbcClientDetailsService(dataSource());    }    @Override    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {        // 设置令牌        clients.withClientDetails(jdbcClientDetails());    }    @Override    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {        // 读取客户端配置        endpoints.tokenStore(tokenStore());    }}

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

spring:  application:    name: oauth2-server  datasource:    type: com.zaxxer.hikari.HikariDataSource    jdbcUrl: jdbc:mysql://127.0.0.1:3306/oauth2?useUnicode=true&characterEncoding=utf-8&useSSL=false    driver-class-name: com.mysql.cj.jdbc.Driver    username: root    password: 12345678    hikari:      minimum-idle: 5      idle-timeout: 600000      maximum-pool-size: 10      auto-commit: true      pool-name: MyHikariCP      max-lifetime: 1800000      connection-timeout: 30000      connection-test-query: SELECT 1server:  port: 8080

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

关于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》](