【Spring Security】 Oauth2 令牌机制
【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。