【Spring Security】 Oauth2 令牌机制

Metadata

title: 【Spring Security】 Oauth2 令牌机制
date: 2023-02-05 15:35
tags:
  - 行动阶段/完成
  - 主题场景/组件
  - 笔记空间/KnowladgeSpace/ProgramSpace/ModuleSpace
  - 细化主题/Module/SpringSecurity
categories:
  - SpringSecurity
keywords:
  - SpringSecurity
description: 【Spring Security】 Oauth2 令牌机制

【Spring Security】 Oauth2 令牌机制

Spring Security Oauth2 利用令牌机制来实现认证授权及[单点登录],获取到令牌后,携带令牌访问资源服务器,资源服务器针对每次访问,都会用令牌去查询认证信息,然后设置到线程 SecurityContextHolder 中,后续操作都会获取到用户信息,简单流程入下图所示:

令牌过期问题

在之前,我们分析过,申请到的令牌都是具有过期时间的,在返回的令牌是有字段显示其多久后会过期,单位为秒

而这个过期时间,默认是无法修改的,如果用户一直在操作页面,令牌过期时间到了,那么资源服务器会返回令牌不可用异常,然后强制用户返回登录界面,这样是非常不可取的。

令牌续签

针对令牌过期的问题,我们可以为令牌续签,所谓令牌续签,就是在令牌快要过期,延长访问令牌过期时间,或者重新返回一个新的访问令牌。

续签方案,大概有以下几种:

  • 重新设置令牌的过期时间:就和 Tomcat 中的 Session 一样,此时令牌有状态,客户端携带令牌访问,资源服务器需要对令牌的过期时间进行判断,比如发现过期时间小于 5 分钟时,重新设置设置过期时间。
  • 使用刷新令牌:直接重新设置访问令牌的过期时间,可能存在安全问题,如果被窃取了这个令牌,那么这个令牌可以一直用,所以可以使用刷新令牌机制,认证通过后,颁发访问令牌和刷新令牌,刷新令牌客户端自己保存,当访问令牌过期时,使用刷新令牌再申请一个新的访问令牌,这样就避免了一些安全问题。

解决方案 1 使用 RedisTokenStore 续签令牌

方案说明

之前分析过,TokenStore 保存了访问令牌对象,认证信息等,TokenStore 支持内存、JDBC、Redis、JWT 等存储,比如 RedisTokenStore 存储的这些信息,就具有过期时间,一旦过期数据被删除,就会抛出令牌不可用异常。

那么如何实现续签功能呢? 我们之前又分析过,携带令牌去访问资源服务器时,都会根据令牌去查询相关的信息,然后设置到线程中,那么只需要每次访问的时候,去校验当前是否快过期,快过期了,则重新设置下过期时间就可以了。

分析 ResourceServerTokenServices 接口

可以使用这种方案的 TokenStore 有 InMemoryTokenStore、JdbcTokenStore、RedisTokenStore,因为 JWT 是无状态的,信息都放在了 JWT 中,如果修改了过期时间信息,那么 JWT 也就变了,所以这个时候最好用刷新令牌机制。

资源服务器根据 Token 查询认证信息的功能是由 ResourceServerTokenServices 提供的,该接口提供了两个核心方法:

public interface ResourceServerTokenServices {

    /**
     * 通过访问令牌(ID)获取认证信息
     */
    OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException;

    /**
     *  通过访问令牌(ID)获取访问令牌对象
     */
    OAuth2AccessToken readAccessToken(String accessToken);

}

该接口有如下 4 个实现类:

  • DefaultTokenServices:既实现了 ResourceServerTokenServices,又实现了 AuthorizationServerTokenServices,既可以创建,也可以获取、刷新令牌。
  • SpringSocialTokenServices:封装了对第三方统一授权的调用方法,比如集成 QQ, 微信, 微博。
  • UserInfoTokenServices:获取鉴权用户信息的服务, 对应的配置文件中 security.oauth2.resource.user-info-uri
  • RemoteTokenServices:远程访问 token 认证信息,比如授权服务器使用内存存储时,需要远程调用获取。

在使用 RedisTokenStore 时,使用的是 DefaultTokenServices 进行令牌和认证信息的查询,那么我们仿照 DefaultTokenServices 在查询信息时,发现快要过期,直接刷新过期时间就可以了。

实现案例

自定义 ResourceServerTokenServices 实现类,主要是判断是否快过期,如果快过期了,则修改过期时间,重新存储令牌

public class PearlResourceServerTokenServices implements ResourceServerTokenServices, InitializingBean {

    private TokenStore tokenStore;

    /**
     * 根据访问令牌值加载用户认证信息
     */
    @Override
    public OAuth2Authentication loadAuthentication(String accessTokenValue) throws AuthenticationException,
            InvalidTokenException {
        // 1. 根据令牌值,获取当前令牌对象
        DefaultOAuth2AccessToken accessToken = (DefaultOAuth2AccessToken) tokenStore.readAccessToken(accessTokenValue);
        if (accessToken == null) {
            // 1.1 令牌对象不存在 抛出Invalid access token 异常
            throw new InvalidTokenException("Invalid access token: " + accessTokenValue);
        } else if (accessToken.isExpired()) {
            // 1.2 存在令牌对象,但是已过期则删除,抛出 Access token expired 令牌过期异常
            tokenStore.removeAccessToken(accessToken);
            throw new InvalidTokenException("Access token expired: " + accessTokenValue);
        }
        // 2. 根据令牌获取认证信息
        OAuth2Authentication result = tokenStore.readAuthentication(accessToken);
        if (result == null) {
            // in case of race condition
            // 2.1 没有认证信息 也会抛出异常Invalid access token
            throw new InvalidTokenException("Invalid access token: " + accessTokenValue);
        }
        // 3. 访问令牌续签
        Date nowTokenExpiration = accessToken.getExpiration();
        Date now = new Date();
        // 3.1 如果过期时间少于5分钟,则重新设置令牌对象的过期时间,并存储
        if ((nowTokenExpiration.getTime() - now.getTime()) < 300_000) {
            accessToken.setExpiration(new Date(System.currentTimeMillis() + (300 * 1000L)));// 设置5分钟过期,时间应该从配置中获取
            tokenStore.storeAccessToken(accessToken, result); // 设置新的令牌 会自动再根据令牌对象过期时间设置 redis过期
        }
        return result;
    }

    /**
     * 读取令牌对象
     */
    @Override
    public OAuth2AccessToken readAccessToken(String accessToken) {
        return tokenStore.readAccessToken(accessToken);
    }

    /**
     * 后置处理 检查TokenStore
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(tokenStore, "tokenStore must be set");
    }

    public TokenStore getTokenStore() {
        return tokenStore;
    }

    public void setTokenStore(TokenStore tokenStore) {
        this.tokenStore = tokenStore;
    }
}

使用 @Bean 注解,注入这些需要的 Bean 对象:

@Configuration
public class ResourceServerConfigBean {
    // 密码解析器
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public TokenStore redisTokenStore(RedisConnectionFactory connectionFactory) {
        RedisTokenStore redisTokenStore = new RedisTokenStore(connectionFactory);
        redisTokenStore.setPrefix("user:session:");// 设置前缀
        return redisTokenStore;
    }

    @Bean
    @Primary
    public PearlResourceServerTokenServices defaultTokenServices(RedisConnectionFactory connectionFactory) {
        PearlResourceServerTokenServices resourceServerTokenServices = new PearlResourceServerTokenServices();
        resourceServerTokenServices.setTokenStore(redisTokenStore(connectionFactory));
        return resourceServerTokenServices;
    }
}

在资源服务器配置类中,添加 tokenServices 为自定义类:

@Configuration
@RequiredArgsConstructor
@EnableResourceServer//标识为资源服务
@EnableWebSecurity(debug = true)
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class MyResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    MyAuthenticationEntryPoint myAuthenticationEntryPoint;

    private final TokenStore tokenStore;

    private final PearlResourceServerTokenServices resourceServerTokenServices;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.tokenServices(resourceServerTokenServices).tokenStore(tokenStore);
        resources.authenticationEntryPoint(myAuthenticationEntryPoint);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        super.configure(http);
    }
}

测试

首先通过授权服务器获取令牌,此时 Redis 中会存储相关数据,可以看到此时的过期时间为 286 秒。

然后通过令牌访问资源服务器,因为我们设置的是过期时间小于 5 分钟则会刷新,所以这里的过期时间变为了 297 秒,续签成功。

最后使用了 Jmeter 压测了以下,发现吞吐量在 500 每秒,并有一些异常发生,

异常原因是因为 Redis 获取不到连接了,可能是因为默认的 RedisTokenStore 没有使用连接池导致,实际开发,需要优化下,比如使用 RedisTemplate。