Spring Security系列(27)- Spring Security Oauth2之令牌过期和续签问题解决方案(1)

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。
在这里插入图片描述

Guess you like

Origin blog.csdn.net/qq_43437874/article/details/121349989