在开始之前,我们需要对以下相关知识有基本的了解。

  1. SpringSecurity:建议查阅官方文档做相关了解。
  2. OAuth2:推荐 阮一峰老师的 OAuth2 介绍,通俗易懂。
  3. SpringBoot OAuth2:官方文档 OAuth 2 Developers Guide
  4. JWT(JSON Web Token):了解它的作用即可,深入了解可前往官网:https://jwt.io/

这里提供我写的一些 Demo,欢迎指出问题。

🚀 源码地址:https://github.com/NekoChips/SpringDemo/11.springboot-oauth2

本文将结合大量代码演示整个功能的实现过程。

1. 框架搭建

这里我使用的版本依赖分别为 SpringBoot 2.1.9.RELEASESpringCloud Greenwich.SR3

👀 友情提示文中还使用了 MybatisPlus 工具,若不需使用,请自行去除依赖以及实体类代码中的相关注解。若连数据库也不使用的,请自行去除数据库连接相关依赖。

1.1 引入相关依赖

新建 maven 项目后,引入相关依赖。

<?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>springboot-oauth2</artifactId>
    <version>1.0-SNAPSHOT</version>

    <name>springboot-oauth2-demo</name>
    <description>Oauth2 Demo for Springboot</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.9.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>1.8</java.version>
        <charset>UTF-8</charset>
        <springcloud.version>Greenwich.SR3</springcloud.version>
        <mybaitplus.version>3.3.0</mybaitplus.version>
    </properties>

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

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

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

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

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

        <!-- 数据库连接依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <!-- 引入 mybatis-plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybaitplus.version}</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-autoconfigure</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!-- 日志 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${springcloud.version}</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>

1.2 实现 UserDetailsService

相信了解过 SpringSecurity 的同学,对这个接口都不会陌生。该接口在实现用户认证功能上起决定性作用。

定义 UserEntity 实体类实现 UserDetails 接口,定义 RoleEntityAuthorityEntity ,均需实现 GrantedAuthority 接口。

@TableName(value = "tb_user_info")
public class UserEntity implements UserDetails {

    private static final long serialVersionUID = -517800012387095551L;

    @TableId(value = "U_ID", type = IdType.ASSIGN_UUID)
    private String userId;

    @TableField(value = "U_NAME")
    private String username;

    @TableField(value = "U_PASSWORD")
    private String password;

    // -1: 过期; 0: 锁定; 1:启用
    @TableField(value = "U_STATUS")
    private Integer status;

    // 角色列表
    @TableField(exist = false)
    private Collection<RoleEntity> roles;

    // 权限列表
    @TableField(exist = false)
    private Collection<AuthorityEntity> authorities;

    public UserEntity() {

    }

    public UserEntity(String username, String password, Integer status) {
        this.username = username;
        this.password = password;
        this.status = status;
    }

    // 获取用户的权限,这里可以配置基于方法的权限,也可以配置基于角色的权限
    // 同样可以把两个都配置进去
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        grantedAuthorities.addAll(this.authorities);
        grantedAuthorities.addAll(this.roles);
        return grantedAuthorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    // 用户是否过期,过期返回false, 没过期返回true
    @Override
    public boolean isAccountNonExpired() {
        return !this.status.equals(CommonConstant.EXPIRED.getValue());
    }

    // 用户是否被锁住,锁定返回false,没锁定返回true
    @Override
    public boolean isAccountNonLocked() {
        return !this.status.equals(CommonConstant.LOCKED.getValue());
    }

    // 判断用户的凭证是否过期, 用于 refresh_token
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 判断用户是否可用
    @Override
    public boolean isEnabled() {
        return this.status.equals(CommonConstant.VALID.getValue());
    }

    // 省略getter、setter
}

RoelEntity 实体类

@TableName(value = "tb_role")
public class RoleEntity implements GrantedAuthority {

    private static final long serialVersionUID = -6827906048760709856L;

    @TableId(value = "R_ID", type = IdType.AUTO)
    private Integer roleId;

    @TableField(value = "R_NAME")
    private String roleName;

    // 权限列表
    @TableField(exist = false)
    private Collection<AuthorityEntity> authorities;

    @Override
    public String getAuthority() {
        return this.roleName;
    }

    // 省略getter、setter
    ......
}

AuthorityEntity 实体类

@TableName(value = "tb_permission")
public class AuthorityEntity implements GrantedAuthority {

    private static final long serialVersionUID = 1426714296619157512L;

    @TableId(value = "PERM_ID", type = IdType.AUTO)
    private Integer authId;

    @TableField(value = "PERM_NAME")
    private String authName;

    @Override
    public String getAuthority() {
        return this.authName;
    }
    // 省略getter、setter
    ......
}

使用 UserServiceImpl 实现 UserDetailsService 接口

@Service
public class UserServiceImpl implements IUserService, UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private IRoleService roleService;

    @Autowired
    private IAuthService authService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * userDetailsService 方法实现
     *
     * @param username 用户名
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = new UserEntity();
        user.setUsername(username);
        QueryWrapper<UserEntity> wrapper = new QueryWrapper<>(user);

        UserEntity existUser = userMapper.selectOne(wrapper);
        // 设置用户方法级权限
        existUser.setAuthorities(listAuthById(existUser.getUserId()));
        // 设置用户角色级权限
        existUser.setRoles(listRoleById(existUser.getUserId()));

        return new User(username, existUser.getPassword(), existUser.isEnabled(),
                existUser.isAccountNonExpired(), existUser.isCredentialsNonExpired(),
                existUser.isAccountNonLocked(), existUser.getAuthorities());
    }

    @Override
    public List<RoleEntity> listRoleById(String userId) {
        return roleService.listByUser(userId);
    }

    @Override
    public List<AuthorityEntity> listAuthById(String userId) {
        List<RoleEntity> roles = listRoleById(userId);
        if (!CollectionUtils.isEmpty(roles)) {
            List<AuthorityEntity> auths = new ArrayList<>();
            roles.forEach(role -> auths.addAll(authService.listByRole(role.getRoleId())));
            return auths;
        }
        return null;
    }
}

这里用户的信息是存放在数据库中,具体的用户信息以及用户权限的查询自己实现。同样也可以创建一个 UserEntity 对象来代替数据库查询结果,代码如下:

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = new UserEntity();
        user.setUsername(username);
        user.setPassword(passwordEncoder.encode("123456"));
        user.setStatus(CommonConstant.VALID.getValue());

        return new User(username, user.getPassword(), user.isEnabled(),
                user.isAccountNonExpired(), user.isCredentialsNonExpired(),
                user.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_ADMIN"));
    }

PasswordEncoder 这个类也是 SpringSecurity 中很关键的一个类,下面马上会介绍到。

2. 配置认证服务器

定义一个认证服务配置类 AuthorizationServerConfig 继承自 WebSecurityConfigurerAdapter,添加相关注解 @Configuration@EnableAuthorizationServer,注解的功能这里不做赘述。在配置类中添加前面说到的 PasswordEncoder 实现并交于 Spring 管理。

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig  extends WebSecurityConfigurerAdapter {
    // 密码加密、校验器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

这时一个简单的 OAuth2 认证环境已经搭建完毕。为了避免 OAuth2 在项目启动时自动创建 client-id 和 client-secret,我们修改一下配置文件。

security:
  oauth2:
    client:
      client-id: test
      client-secret: test_123456

3. 获取 token 令牌

3.1 授权码方式获取令牌

到这里就需要了解 OAuth2 的几种申请令牌的方式了。这里再简单的介绍一下(安全级别由高到低)

  1. authorization_code :授权码模式
  2. implicit :隐藏模式(多应用于纯前端应用)
  3. password :密码模式
  4. client_credentials :凭证式(仅限于客户端)

启动项目,打开浏览器访问 http://localhost:8080/oauth/authorize?response_type=code&client_id=test&redirect_uri=https://chenyangjie.com.cn&scope=all&state=123

参数说明:

  • response_type=code 表示授权码模式
  • client_id=test 配置文件中配置的 client-id
  • redirect_uri 重定向地址,用于获取授权码,这里我随便写的一个地址
  • scop=all 权限作用域,表示所有权限

由于我们的认证服务器继承自 WebSecurityConfigurerAdapter ,所以访问后会跳转到 SpringSecurity 的登录页面。

image.png

根据前面定义的 UserServiceImpl 逻辑进行登录验证。登录成功后会默认跳至 sw.js ,然后报 404 错误。撇开它不管,我们继续访问上述地址,页面跳转如下:

image.png

明显告诉我们需要在配置文件中指定 redirect_uri 。

security:
  oauth2:
    client:
      client-id: test
      client-secret: test_123456
      registered-redirect-uri: http://chenyangjie.com.cn

重启项目,再次进行如上步骤,登录成功后再次访问的跳转页面如下:

image.png

选择 Approve 同意授权,然后点击 Authorize 按钮,页面会跳至指定的 redirect_uri,并带上了授权码信息,地址栏信息如下:

https://chenyangjie.com.cn/?code=KiaeNt&state=123

其中 code 后面的值即为授权码。然后我们就可以拿着这个授权码从认证服务器上获取 token 令牌了。

使用 postman 发送 post 请求,切记是 post 请求,请求地址:localhost:8080/oauth/token,请求参数如下:

image.png

除了这些参数外,还需在请求头中新增 Authorization 参数

image.png

Authorization 的值为 Basic client-id:clinet-secret 的值经过 base64 加密后的值。发送请求后,获取到 token 令牌:

{
    "access_token": "f592c381-46d1-41f6-8956-1f831013e1ff",
    "token_type": "bearer",
    "refresh_token": "b9ff22e6-0e79-4c8e-a75f-df252553d4a5",
    "expires_in": 43199,
    "scope": "all"
}

其中 access_token 即为获取到的 token 令牌,refresh_token 为刷新令牌,后面会讲到。

一个授权码只能获取一次令牌,再次发送获取令牌请求,结果如下:

{
    "error": "invalid_grant",
    "error_description": "Invalid authorization code: KiaeNt"
}

3.2 密码方式获取令牌

使用 postman 发送 post 请求,请求地址:localhost:8080/oauth/token,请求参数如下:

image.png

同样的也需要在请求头中添加 Authorization 信息,与授权码方式一样。同样也能获得 token:

{
    "access_token": "f592c381-46d1-41f6-8956-1f831013e1ff",
    "token_type": "bearer",
    "refresh_token": "b9ff22e6-0e79-4c8e-a75f-df252553d4a5",
    "expires_in": 35423,
    "scope": "all"
}

4. 配置资源服务器

这里有一个很重要的概念。资源服务器,顾名思义,只有在配置了资源服务器时,才可以调用服务器上的资源。

我们先创建一个 Controller,用于需要访问的资源。

@RestController
@RequestMapping("test")
public class TestController {

    @GetMapping("authInfo")
    public Authentication getAuth(Authentication authentication) {
        return authentication;
    }
}

下面我们分 没配置资源服务器配置了资源服务器 两种情况对该资源进行访问。

4.1 没配置资源服务器

先使用密码方式获取 token 令牌,令牌如下:

{
    "access_token": "d27a5db5-bc74-413e-ac23-c00133f3dbcb",
    "token_type": "bearer",
    "refresh_token": "cd32fb38-fbc6-402e-8cbe-f9c9724c0431",
    "expires_in": 43199,
    "scope": "all"
}

使用 token 令牌对资源发起请求,在请求头中添加 Authorization 参数,值为 token_type access_token

image.png

返回值如下:

{
    "timestamp": "2020-03-14T12:55:34.517+0000",
    "status": 401,
    "error": "Unauthorized",
    "message": "Unauthorized",
    "path": "/authInfo"
}

很奇怪,明明使用了密码方式获取的 token 来访问资源,却还是报了没有认证的错误。这就是没有配置资源服务器导致的,服务器认为这个 token 它并不合法。

4.2 配置了资源服务器

首先我们需要配置一个资源服务器,定义 ResourceServerConfig 类,并添加 @EnableResourceServer 注解

@Configuration
@EnableResourceServer
public class ResourceServerConfig {

}

重启服务,再次访问资源,成功获取到了资源信息,返回值如下:

{
    "authorities": [
        {
            "authority": "ROLE_ADMIN"
        }
    ],
    "details": {
        "remoteAddress": "127.0.0.1",
        "sessionId": null,
        "tokenValue": "ae31ad4c-f218-4899-aa3b-48a2b617d237",
        "tokenType": "bearer",
        "decodedDetails": null
    },
    ......
}

👀 这里要说明一下,如果在同一个服务模块中同时配置了认证服务器和资源服务器,再去使用授权码模式去获取令牌有可能

Full authentication is required to access this resource 的问题,此时需要使用 @Order() 注解保证认证服务器的配置优先级高于资源服务器即可。

5. 自定义 token 管理

之前我们获取到的 token 令牌都是基于 SpringSecurity OAuth2 默认配置生成的,同时 SpringSecurity 允许用户去自定义 token 配置。SpringSecurity 还支持使用 JWT 来替换默认的令牌,进一步提升对服务资源的安全保护。

5.1 ClientDetailsService

UserDetailsService 功能类似,ClientDetailsService 用于认证客户端信息。查看其源码,发现它有两个子类实现 InMemoryClientDetailsServiceJdbcClientDetailsService

image.png

🚀 这里我重点说一下 JdbcClientDetailsService,至于 InMemoryClientDetailsService 就不做赘述了。

👀 对于不想使用数据库的同学,本小结的以下部分可选择跳过。

JdbcClientDetailsService 顾名思义,是需要连接数据库的。

	......

	private static final String CLIENT_FIELDS_FOR_UPDATE = "resource_ids, scope, "
			+ "authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, "
			+ "refresh_token_validity, additional_information, autoapprove";
	private static final String CLIENT_FIELDS = "client_secret, " + CLIENT_FIELDS_FOR_UPDATE;
	private static final String BASE_FIND_STATEMENT = "select client_id, " + CLIENT_FIELDS
			+ " from oauth_client_details";
	private static final String DEFAULT_INSERT_STATEMENT = "insert into oauth_client_details (" + CLIENT_FIELDS
			+ ", client_id) values (?,?,?,?,?,?,?,?,?,?,?)";

	......

通过查看其源码,不难发现我们需要建一张名为 oauth_client_details 的表。建表语句如下:

-- Table structure for oauth_client_details 
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details`
(
    `client_id`               varchar(48) NOT NULL,
    `resource_ids`            varchar(256)  DEFAULT NULL,
    `client_secret`           varchar(256)  DEFAULT NULL,
    `scope`                   varchar(256)  DEFAULT NULL,
    `authorized_grant_types`  varchar(256)  DEFAULT NULL,
    `web_server_redirect_uri` varchar(256)  DEFAULT NULL,
    `authorities`             varchar(256)  DEFAULT NULL,
    `access_token_validity`   int(11)       DEFAULT NULL,
    `refresh_token_validity`  int(11)       DEFAULT NULL,
    `additional_information`  varchar(4096) DEFAULT NULL,
    `autoapprove`             varchar(256)  DEFAULT NULL,
    PRIMARY KEY (`client_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

至此 ClientDetails 的数据库环境搭建好了,我们可以将认证的客户端信息放在该表中用于查询验证。

5.2 自定义令牌配置

之前的认证服务器 AuthorizationServerConfig 继承自 SpringSecurity 的安全配置适配器 WebSecurityConfigurerAdapter,现在我们让它继承自 OAuth2AuthorizationServerConfigurerAdapter

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    // 注入认证管理器
    @Autowired
    private AuthenticationManager authenticationManager;

    // 注入用户认证信息查询接口
    @Autowired
    private UserDetailsService userService;

    // 注入 DataSource
    @Autowired
    private DataSource dataSource;

    // 密码加密、校验器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 注入 jdbcClientDetailsService
    @Bean
    public ClientDetailsService jdbcClientDetailsService() {
        return new JdbcClientDetailsService(dataSource);
    }

    // 客户端认证配置
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(jdbcClientDetailsService());
    }

    // 配置认证端点
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)
                .userDetailsService(userService);
    }
}

👀 不想使用数据库的同学可以使用 inMemory 的方式配置客户端认证,同时无需注入 DataSource 以及注册 ClientDetailsService ,客户端配置代码更改如下:

    // 客户端认证配置
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("test")
                .secret("test_123456")
                .accessTokenValiditySeconds(3600)
                .scopes("all")
                .authorizedGrantTypes("password");
    }

配置完认证服务器之后,需要再新增一个 SpringSecurity 的配置,并注册认证服务器中用到的认证管理器 AuthenticationManager

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManagerBean();
    }
}

启动项目,使用密码模式获取客户端“test”的令牌:

{
    "error": "unauthorized",
    "error_description": "Full authentication is required to access this resource"
}

查看控制台告警,提示我们 Encoded password does not look like BCrypt

WARN 10736 --- [0.1-8080-exec-1] o.s.s.c.bcrypt.BCryptPasswordEncoder     : Encoded password does not look like BCrypt

根据指示使用 PasswordEncoder 对客户端密码进行加密。

👀 使用数据库的同学再添加客户端信息时需要对客户端的密码进行加密,源码中有提供,这里不做赘述。

    // 客户端认证配置
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("test")
                .secret(passwordEncoder().encode("test_123456"))
                .accessTokenValiditySeconds(3600)
                .scopes("all")
                .authorizedGrantTypes("password");
    }

重启项目,使用密码方式获取客户端“test”的令牌:

{
    "access_token": "0351a6c2-215b-4f11-85fb-fd11319082ca",
    "token_type": "bearer",
    "expires_in": 3599,
    "scope": "all"
}

🚀 这里可能会出现一个服务告警,导致无法获取令牌,成功获取令牌的可跳过该段内容

WARN 9508 --- [0.1-8080-exec-2] o.s.s.o.provider.endpoint.TokenEndpoint  : Handling error: NestedServletException, Handler dispatch failed; nested exception is java.lang.StackOverflowError

经过 debug ,发现 security 源码在用户校验时会进入一个死循环,从而导致内存溢出。

image.png

👀 这个地方认证失败的原因是,前面有一个步骤将参数中的密码移除了,导致现在参数中没有密码,从而导致认证不通过。由此可以推断出可能是注册的 AuthenticationManager 有问题,尝试换一种注册方式。

👀 (具体是什么原因引起的还有待验证

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
//        return super.authenticationManagerBean();
    }
}

👀 保险起见移除配置文件中的 OAuth2 相关配置:

#security:
#  oauth2:
#    client:
#      client-id: test
#      client-secret: test_123456
#      registered-redirect-uri: https://chenyangjie.com.cn

重启项目,使用密码方式获取令牌

{
    "access_token": "0351a6c2-215b-4f11-85fb-fd11319082ca",
    "token_type": "bearer",
    "expires_in": 3575,
    "scope": "all"
}

6. 令牌储存策略

🚀 细心的同学会发现上面我们使用密码方式获取的两次令牌是同一个令牌,只是过期时间 expires_in 有所变化

// 第一次
{
"access_token": "0351a6c2-215b-4f11-85fb-fd11319082ca",
"token_type": "bearer",
"expires_in": 3599,
"scope": "all"
}

// 第二次
{
"access_token": "0351a6c2-215b-4f11-85fb-fd11319082ca",
"token_type": "bearer",
"expires_in": 3575,
"scope": "all"
}

😄 这是因为令牌的默认储存策略是存储在内存中的,同时它也支持第三方存储,如 Redis 。

6.1 使用 Redis 存储令牌

首先创建 RedisTokenConfig,并注册 RedisTokenStore

@Configuration
public class RedisTokenConfig{

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    // 注册RedisTokenStore
    @Bean
    public TokenStore redisTokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}

然后在认证服务器中指定令牌储存策略

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

........

    // 注入 RedisTokenStore
    @Autowired
    private TokenStore redisTokenStore;


    // 配置认证端点
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)
                .userDetailsService(userService)
                .tokenStore(redisTokenStore);
    }

........
}

重启项目获取令牌,验证令牌存储

image.png

7. 集成 JWT 令牌

👀 在集成之前,需要对 JWT 有一个基本的认识。

在使用方式上与使用 RedisTokenStore 有点类似。

7.1 使用 JWT 令牌

创建一个 JwtTokenConfig ,注册 JwtAccessTokenConverterJwtTokenStore

@Configuration
public class JwtTokenConfig {

    private static final String PUBLIC_KEY = "public_key";

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter tokenConverter = new JwtAccessTokenConverter();
        tokenConverter.setSigningKey(PUBLIC_KEY);
        return tokenConverter;
    }

    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
}

👀 注释掉 RedisTokenConfig 上的 @Configuration 注解,或者直接删除 RedisTokenConfig 代码,避免发生注册冲突

修改认证服务器相关配置

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

......

    // 注入 RedisTokenStore
//    @Autowired
//    private TokenStore redisTokenStore;

    // 注入 JwtAccessTokenConverter
    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    // 注入 JwtTokenStore
    @Autowired
    private TokenStore jwtTokenStore;

    // 配置认证端点
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)
                .userDetailsService(userService)
//                .tokenStore(redisTokenStore)
                .accessTokenConverter(jwtAccessTokenConverter)
                .tokenStore(jwtTokenStore);
    }

......
}

重启项目,获取令牌

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODQyNDY5NjUsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iXSwianRpIjoiMDY4ZDNiODUtZGEyZS00M2M1LWFiZjAtZGNiNTUxMDg2NDRjIiwiY2xpZW50X2lkIjoidGVzdCIsInNjb3BlIjpbImFsbCJdfQ.uySQMR5wh528q7QtaJAW2CKeX5kd9ZXiL63ETGvF0GY",
    "token_type": "bearer",
    "expires_in": 3598,
    "scope": "all",
    "jti": "068d3b85-da2e-43c5-abf0-dcb55108644c"
}

使用 jwt 令牌访问资源,同样可以成功访问。

image.png

7.2 解析 JWT 令牌

可以去 jwt 的官网进行解析,https://jwt.io

image.png

也可以使用 Java 实现对 JWT 的解析,这里不做赘述。

具体实现可以参照另一篇文章: 初探 JWT

7.3 JwtToken 增强

一般来说 jwt 的安全性已经得到了一定的保障,不过 Springsecurity OAuth2 提供了一个 TokenEnhance r 的 Token 增强器接口,我们通过实现他可以让 JWT 的安全性进一步提升。

首先写一个 TokenEnhancer 的实现类

public class JwtTokenEnhancer implements TokenEnhancer {

    // 实际开发中这里可以是一个 密钥
    private static final String PRIVATE_SECRET = "private_secret";

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> enhanceMap = new HashMap<>();
        // 增强的内容
        enhanceMap.put("secret", PRIVATE_SECRET);
        ((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(enhanceMap);
        return accessToken;
    }
}

JwtTokenConfig 中注册这个增强类

@Configuration
public class JwtTokenConfig {

......

    @Bean
    public TokenEnhancer jwtTokenEnhancer() {
        return new JwtTokenEnhancer();
    }
}

配置认证服务器

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

......

    // 注入 JwtAccessTokenConverter
    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    // 注入 JwtTokenStore
    @Autowired
    private TokenStore jwtTokenStore;

    // 注入 JwtTokenEnhancer
    @Autowired
    private TokenEnhancer jwtTokenEnhancer;

    // 配置认证端点
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> enhancers = new ArrayList<>();
        enhancers.add(jwtTokenEnhancer);
        enhancers.add(jwtAccessTokenConverter);
        enhancerChain.setTokenEnhancers(enhancers);

        endpoints
                .authenticationManager(authenticationManager)
                .userDetailsService(userService)
//                .tokenStore(redisTokenStore)
                .accessTokenConverter(jwtAccessTokenConverter)
                .tokenStore(jwtTokenStore)
                .tokenEnhancer(enhancerChain);
    }

......

}

重启项目,获取令牌,解析令牌得到的数据为:

{
  "user_name": "admin",
  "scope": [
    "all"
  ],
  "secret": "private_secret",
  "exp": 1584249003,
  "authorities": [
    "admin"
  ],
  "jti": "09c340bd-e240-47d1-b937-a3679e48b940",
  "client_id": "test"
}

可以发现我们增强的东西,也被加密到 jwt 中了,使用这个增强的 jwt 也可以访问资源,这里就不演示了。

8. 刷新令牌 refresh_token

🚀 我们平时在使用软件的过程中,如果一个软件是通过第三方应用授权登录的,每次使用的间隔较短时,系统不会提示我们需要重新登录或者授权,不过如果长时间没有登录,系统就会提示需要重新登录或者授权。

👀 在这里,刷新令牌的作用就和上面的应用场景类似了,当我们申请的令牌过期了或者快要过期了,可以通过刷新令牌从认证服务中重新获取一个新的令牌,以维持授权可用。

👀 我们可以发现,在我们前面的例子中,返回的令牌信息中没有包含 refresh_token

8.1 配置 refresh_token

为了让返回的令牌信息包含 refresh_token ,我们需要进行相关配置。

查看文档发现 SpringSecurity OAuth2refresh_token 当做了除四种标准的获取令牌方式之外的另一种拓展方式,即 refresh_token 方式获取令牌。说到这里,想必都明白如何去进行配置了。

👀 使用数据库管理客户端信息的同学,只需要在对应客户端的 authorized_grant_types 字段中新 refresh_token 方式即可。

👀 对于将客户端数据放在内存中管理的同学,修改认证服务器中的配置即可:

    // 客户端认证配置
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//        clients.withClientDetails(jdbcClientDetailsService());
        clients.inMemory()
                .withClient("test")
                .secret(passwordEncoder().encode("test_123456"))
                .accessTokenValiditySeconds(3600)
                .refreshTokenValiditySeconds(86400)
                .scopes("all")
                .authorizedGrantTypes("password","refresh_token");

    }

这里我将 refresh_token 的过期时间设为 1 天

重启项目,获取 token 信息如下:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJzZWNyZXQiOiJwcml2YXRlX3NlY3JldCIsImV4cCI6MTU4NDI1MTA4MSwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iXSwianRpIjoiMjM2NjY4ODItM2I3My00YTY0LTgzNjAtZmY0ZGNlMGRkMDhhIiwiY2xpZW50X2lkIjoidGVzdCJ9.ttPBy4qj6w98aHvnBLsDUbD_fAxVpOXKpxsWMs5WnEs",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiIyMzY2Njg4Mi0zYjczLTRhNjQtODM2MC1mZjRkY2UwZGQwOGEiLCJzZWNyZXQiOiJwcml2YXRlX3NlY3JldCIsImV4cCI6MTU4NDMzMzg4MSwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iXSwianRpIjoiYzQ0ZjU2MjAtMmYwOS00NmYxLTkwYWItOWFjNzQ1MWMzNjQ1IiwiY2xpZW50X2lkIjoidGVzdCJ9.77kAVWdVk86h-gC3MSKGt8JJScOlUe9-wEBqp4U1GnI",
    "expires_in": 3599,
    "scope": "all",
    "secret": "private_secret",
    "jti": "23666882-3b73-4a64-8360-ff4dce0dd08a"
}

8.2 使用 refresh_token 获取新的 token

使用 postman 发送获取 token 的 post 请求,请求地址:localhost:8080/oauth/token,参数如下:

image.png

同样也需要带上请求头中的 Authorization 信息,获取令牌信息如下:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJzZWNyZXQiOiJwcml2YXRlX3NlY3JldCIsImV4cCI6MTU4NDI1MTMzNiwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iXSwianRpIjoiY2NjZjY0ZDAtOTc4YS00MjUzLWI0ZGUtNmM5ZjEzMmExZjY2IiwiY2xpZW50X2lkIjoidGVzdCJ9.mwYWgoRNtjhgjp8thRimiFm-RTmoWnITtELtHjCM6zw",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiJjY2NmNjRkMC05NzhhLTQyNTMtYjRkZS02YzlmMTMyYTFmNjYiLCJzZWNyZXQiOiJwcml2YXRlX3NlY3JldCIsImV4cCI6MTU4NDMzMzg4MSwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iXSwianRpIjoiYzQ0ZjU2MjAtMmYwOS00NmYxLTkwYWItOWFjNzQ1MWMzNjQ1IiwiY2xpZW50X2lkIjoidGVzdCJ9.0q8BrAADIGSZfuKqsTQR8tiJ4EaULqkc8DY2T20X8U0",
    "expires_in": 3599,
    "scope": "all",
    "secret": "private_secret",
    "jti": "cccf64d0-978a-4253-b4de-6c9f132a1f66"
}

我们可以使用新的令牌去获取资源信息,这里就不做演示。

9. 总结

至此,使用 SpringSecurity OAuth2 实现应用授权的思路和简单的 Demo 基本完成。

❤️ 感谢看到这里还有帮忙验证的小伙伴。

🚀 源码地址:https://github.com/NekoChips/SpringDemo/11.springboot-oauth2

🚀 相关链接:

  1. OAuth2 介绍以及使用:阮一峰老师的 OAuth2 介绍
  2. SpringBoot OAuth2 官方文档: OAuth 2 Developers Guide
  3. JWT 介绍以及使用: 初探 JWT
  4. SpringSecurity OAuth2 实现单点登录:Spring Security OAuth2 SSO 搭建指南
  5. SpringSecurity 权限管理: Spring Security 使用指南

😄 进阶路途贵在不断的学习和积累,希望各位读者能积极指出文中错误。


关于作者:NekoChips
本文地址:https://chenyangjie.com.cn/articles/2020/03/14/1584194721393.html
版权声明:本篇所有文章仅用于学习和技术交流,本作品采用 BY-NC-SA 4.0 许可协议,如需转载请注明出处!
许可协议:知识共享许可协议