Spring Security系列(25)-Spring Security Oauth2之RedisTokenStore使用详解及源码分析

前言

上篇文档我们分析了Security登录后将用户信息保存在Session中,用户访问时,会获取Session中的数据绑定到ThreadLocal中。

Oauth2颁发令牌端点源码解析我们分析了在Spring Security oauth2中,是使用授权模式去获取令牌,而令牌的对应信息是保存在了TokenStore中。
在这里插入图片描述
Oauth2授权服务之令牌管理源码分析及使用JWT令牌案例中,分析了TokenStore及如何使用JWT令牌,但是也发现JWT令牌存在诸多问题。

所以接下来了解下如何使用RedisTokenStore在Redis中存储令牌的认证信息。

TokenStore

TokenStore,意为令牌存储,是一个接口,主要定义了存储及获取令牌信息的一些方法:

public interface TokenStore {
    
    
	// 使用OAuth2AccessToken 或者字符串读取认证信息
    OAuth2Authentication readAuthentication(OAuth2AccessToken var1);
    OAuth2Authentication readAuthentication(String var1);
    
	// 存储访问令牌,传入AccessToken和Authentication对象
    void storeAccessToken(OAuth2AccessToken var1, OAuth2Authentication var2);
	// 读取AccessToken
    OAuth2AccessToken readAccessToken(String var1);
	// 传入AccessToken,移除AccessToken
    void removeAccessToken(OAuth2AccessToken var1);
	// 存储刷新令牌
    void storeRefreshToken(OAuth2RefreshToken var1, OAuth2Authentication var2);
	// 读取刷新令牌
    OAuth2RefreshToken readRefreshToken(String var1);
	// 使用刷新令牌获取认证对象
    OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken var1);
	// 移除刷新令牌
    void removeRefreshToken(OAuth2RefreshToken var1);
	// 使用刷新令牌移除访问令牌
    void removeAccessTokenUsingRefreshToken(OAuth2RefreshToken var1);
	// 使用Authentication获取AccessToken
    OAuth2AccessToken getAccessToken(OAuth2Authentication var1);
	// 使用用户名和ClientId获取访问访问令牌集合
    Collection<OAuth2AccessToken> findTokensByClientIdAndUserName(String var1, String var2);
	// 使用ClientId获取访问访问令牌集合
    Collection<OAuth2AccessToken> findTokensByClientId(String var1);
}

在spring-security-oauth2模块中的org.springframework.security.oauth2.provider.token.store包下定义了五种存储令牌的方式。
在这里插入图片描述

1. JdbcTokenStore

JdbcTokenStore是采用数据库来存储,可以看到该类定义了很多SQL语句用来管理Token。
在这里插入图片描述
该类直接使用spring-jdbc提供的JdbcTemplate模板类进行SQL操作。
在这里插入图片描述
JdbcTokenStore并不是一个很好的方式,因为数据库查询还是比较消耗性能的。

2. JwtTokenStore

JwtTokenStore使用JWT令牌来存储,这是目前用的比较多的方式了,直接将令牌及认证信息添加到令牌中,返回一个很长的令牌给客户端,服务端不需要存储,访问时直接携带这个访问,资源服务器直接在令牌中获取认证信息。

在DefaultTokenServices中的createAccessToken方法创建Token的时候,最后会判断有没有令牌增强器,如果有则会调用AccessTokenConverter来进行增强处理,变为JWT令牌。
在这里插入图片描述
而在使用JwtTokenStore时,是需要注入一个JwtAccessTokenConverter(JWT访问令牌增强器)的:

    @Bean
    public TokenStore jwtTokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
    
    
        return new JwtTokenStore(jwtAccessTokenConverter);
    }
    // Jwt转换器
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
    
    
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("123456");
        return converter;

3. InMemoryTokenStore

InMemoryTokenStore是采用内存来存储,这是默认的存储方式,可以看到其内部维护了很多ConcurrentHashMap:
在这里插入图片描述
由于认证通过后,认证信息在当前授权服务器的内存中,携带令牌访问其他资源服务器的接口时,则需要配置授权服务器的地址了,然后让资源服务器远程去获取当前令牌的用户信息,所以资源服务器在使用InMemoryTokenStore时,就需要以下这些配置:

security:
  oauth2:
    client:
      client-id: client
      client-secret: secret
      access-token-uri: http://localhost:20000/oauth/token
      user-authorization-uri: http://localhost:20000/oauth/authorize
    resource:
      token-info-uri: http://localhost:20000/oauth/check_token

所以这种方式一般也不用。

4. JwkTokenStore

Jwk和JWT很像,简单来说,这里的Jwk就是使用了JSON Web Signature (JWS)的JWT令牌,也就是使用了在线签名的JWT。

JwkTokenStore的唯一责任是解码JWT并使用相应的JWK验证它的签名(JWS)。

可以从它的构造方法看出,它实际还是一个JwtTokenStore,只是定义了一个特殊的转换器。
在这里插入图片描述

5. RedisTokenStore

RedisTokenStore是使用Redis来存储,首先我们看下它的成员属性:

	//  一些字符串常量
    private static final String ACCESS = "access:";
    private static final String AUTH_TO_ACCESS = "auth_to_access:";
    private static final String AUTH = "auth:";
    private static final String REFRESH_AUTH = "refresh_auth:";
    private static final String ACCESS_TO_REFRESH = "access_to_refresh:";
    private static final String REFRESH = "refresh:";
    private static final String REFRESH_TO_ACCESS = "refresh_to_access:";
    private static final String CLIENT_ID_TO_ACCESS = "client_id_to_access:";
    private static final String UNAME_TO_ACCESS = "uname_to_access:";
    private static final boolean springDataRedis_2_0 = ClassUtils.isPresent("org.springframework.data.redis.connection.RedisStandaloneConfiguration", RedisTokenStore.class.getClassLoader());
    // redis 连接工厂
    private final RedisConnectionFactory connectionFactory;
    // key生成策略
    private AuthenticationKeyGenerator authenticationKeyGenerator = new DefaultAuthenticationKeyGenerator();
    // 序列化策略
    private RedisTokenStoreSerializationStrategy serializationStrategy = new JdkSerializationStrategy();
    // 前缀
    private String prefix = "";
    private Method redisConnectionSet_2_0;

接着看下storeAccessToken(存储访问令牌)接口是如何存入Redis的

	// 参数token对象:包含了令牌值过期时间授权范围等信息  authentication:用户及权限信息
	@Override
	public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
    
    
		// 1. 序列化
		// 序列化token对象
		byte[] serializedAccessToken = serialize(token);
		// 序列化 authentication对象
		byte[] serializedAuth = serialize(authentication);
		// 序列化accessKey 格式为: prefix(前缀)+ access:fe8adf14-79d8-494f-87d5-74b6c7a608fb
		byte[] accessKey = serializeKey(ACCESS + token.getValue());
		// 序列化authKey  格式为:prefix(前缀)+ auth:fe8adf14-79d8-494f-87d5-74b6c7a608fb
		byte[] authKey = serializeKey(AUTH + token.getValue());
		// 序列化authToAccessKey  格式为:auth_to_access:287b1b4095d75bc94942ea499ad78a0c
		byte[] authToAccessKey = serializeKey(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication));
		//  序列化approvalKey 格式为:uname_to_access:client:user
		byte[] approvalKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(authentication));
		// 序列化clientId 格式为:client_id_to_access:client
		byte[] clientId = serializeKey(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId());

		RedisConnection conn = getConnection();
		try {
    
    
			conn.openPipeline();
			if (springDataRedis_2_0) {
    
    
				try {
    
    
					// 2. 存储Redis 
					// 存储access:fe8adf14-79d8-494f-87d5-74b6c7a608fb=》token 对象
					this.redisConnectionSet_2_0.invoke(conn, accessKey, serializedAccessToken);
					// 存储auth:fe8adf14-79d8-494f-87d5-74b6c7a608fb=》authentication 对象
					this.redisConnectionSet_2_0.invoke(conn, authKey, serializedAuth);
					// 存储auth_to_access:287b1b4095d75bc94942ea499ad78a0c=》token 对象
					this.redisConnectionSet_2_0.invoke(conn, authToAccessKey, serializedAccessToken);
				} catch (Exception ex) {
    
    
					throw new RuntimeException(ex);
				}
			} else {
    
    
				conn.set(accessKey, serializedAccessToken);
				conn.set(authKey, serializedAuth);
				conn.set(authToAccessKey, serializedAccessToken);
			}
			if (!authentication.isClientOnly()) {
    
    
				// 3. 存在用户信息,则 存储 uname_to_access:client:user=》token 对象
				conn.sAdd(approvalKey, serializedAccessToken);
			}
			// 存储client_id_to_access:client=》 token 对象
			conn.sAdd(clientId, serializedAccessToken);
			if (token.getExpiration() != null) {
    
    
				// 4. 设置过期时间
				int seconds = token.getExpiresIn();
				conn.expire(accessKey, seconds);
				conn.expire(authKey, seconds);
				conn.expire(authToAccessKey, seconds);
				conn.expire(clientId, seconds);
				conn.expire(approvalKey, seconds);
			}
			OAuth2RefreshToken refreshToken = token.getRefreshToken();
			// 5. 设置刷新令牌(令牌包含令牌值及过期时间)
			if (refreshToken != null && refreshToken.getValue() != null) {
    
    
				// 序列化刷新令牌(值)
				byte[] refresh = serialize(token.getRefreshToken().getValue());
				// 序列化访问令牌(值)
				byte[] auth = serialize(token.getValue());
				// 序列化刷新令牌值 refresh_to_access:117627b6-ce6f-4707-a9c6-96262779b02b
				byte[] refreshToAccessKey = serializeKey(REFRESH_TO_ACCESS + token.getRefreshToken().getValue());
				// 序列化访问令牌值 access_to_refresh:fe8adf14-79d8-494f-87d5-74b6c7a608fb
				byte[] accessToRefreshKey = serializeKey(ACCESS_TO_REFRESH + token.getValue());
				if (springDataRedis_2_0) {
    
    
					try {
    
    
						// 存储 refresh_to_access:117627b6-ce6f-4707-a9c6-96262779b02b=》 访问令牌值
						this.redisConnectionSet_2_0.invoke(conn, refreshToAccessKey, auth);
						// 存储 access_to_refresh:fe8adf14-79d8-494f-87d5-74b6c7a608fb=》 刷新令牌值
						this.redisConnectionSet_2_0.invoke(conn, accessToRefreshKey, refresh);
					} catch (Exception ex) {
    
    
						throw new RuntimeException(ex);
					}
				} else {
    
    
					conn.set(refreshToAccessKey, auth);
					conn.set(accessToRefreshKey, refresh);
				}
				if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
    
    
					ExpiringOAuth2RefreshToken expiringRefreshToken = (ExpiringOAuth2RefreshToken) refreshToken;
					Date expiration = expiringRefreshToken.getExpiration();
					// 设置过期时间
					if (expiration != null) {
    
    
						int seconds = Long.valueOf((expiration.getTime() - System.currentTimeMillis()) / 1000L)
								.intValue();
						conn.expire(refreshToAccessKey, seconds);
						conn.expire(accessToRefreshKey, seconds);
					}
				}
			}
			conn.closePipeline();
		} finally {
    
    
			conn.close();
		}
	}

最终在Redis中,存储了以下数据:
在这里插入图片描述
具体说明如下:

Key 说明 alue
access:120bac96-88e9-4d57-9c98-2ceba2dab6e4 通过访问令牌获取令牌信息 访问令牌对象数据,包含令牌值,过期时间,授权范围等
access_to_refresh:120bac96-88e9-4d57-9c98-2ceba2dab6e4 访通过访问令牌获取刷新令牌 刷新令牌对象
auth:120bac96-88e9-4d57-9c98-2ceba2dab6e4 通过访问令牌获取认证信息 authentication 对象
auth_to_access:287b1b4095d75bc94942ea499ad78a0c 通过authentication 获取访问令牌(key采用用户名、clientID、授权范围MD5加密) 访问令牌对象
client_id_to_access:client 使用cliend ID 获取 token 对象 token 对象
refresh:c8ab74e1-79e3-4161-825e-53b5991f8455 通过刷新令牌值获取刷新令牌对象 刷新令牌对象
refresh_auth:c8ab74e1-79e3-4161-825e-53b5991f8455 通过刷新令牌获取 认证信息 authentication 对象
refresh_to_access:c8ab74e1-79e3-4161-825e-53b5991f8455 通过刷新令牌获取访问令牌 访问令牌对象
uname_to_access:client:user 使用clientID和用户名获取访问令牌 token对象

使用RedisTokenStore

RedisTokenStore的使用很简单,只需要集成Redis,配置RedisTokenStore到容器中就可以了。

如果项目没有集成Redis则可以使用spring-boot-starter-data-redis,这里就不赘述了。

1. 授权服务器改造

@Configuration配置类添加Bean:


    @Bean
    public TokenStore jwtTokenStore(RedisConnectionFactory connectionFactory) {
    
    
        return new RedisTokenStore(connectionFactory);
    }

注入到授权服务器配置类中,并在端点配置类中添加redisTokenStore。

    @Autowired
    private TokenStore redisTokenStore;
	
	    // 端点配置
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    
    
        // 配置端点允许的请求方式
        endpoints.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
        // 配置认证管理器
        endpoints.authenticationManager(authenticationManager);
        // 自定义异常翻译器,用于处理OAuth2Exception
        endpoints.exceptionTranslator(myWebResponseExceptionTranslator);
        // 重新组装令牌颁发者,加入自定义授权模式
        endpoints.tokenGranter(getTokenGranter(endpoints));
/*      // 添加JWT令牌
        // JWT令牌转换器
        endpoints.accessTokenConverter(jwtAccessTokenConverter);
        // Redis 存储令牌*/
        endpoints.tokenStore(redisTokenStore);
    }

2. 资源服务器改造

资源服务器则只需要在ResourceServerConfigurerAdapter配置类中添加RedisTokenStore类型的Bean就可以了。

    @Bean
    public TokenStore tokenStore(RedisConnectionFactory connectionFactory) {
    
    
        return new RedisTokenStore(connectionFactory);
    }

3. 测试

使用密码模式申请令牌,可以看到令牌的开头为a80xx。
在这里插入图片描述
在Redis可以看到当前令牌的相关信息:
在这里插入图片描述

可以看到这些数据是有过期时间的,时间为设置令牌的过期时间,访问令牌和刷新令牌的时间是可以分别设置的,一旦过期时间到了,这些数据就会被删除。
在这里插入图片描述

然后使用该令牌去访问资源服务器接口,发现认证通过,集成没问题。

在带令牌访问资源服务器时,OAuth2AuthenticationProcessingFilter就会去缓存中查询认证信息。
在这里插入图片描述
在上篇文档中,我们了解到了Security登录后用户信息保存在Session中,那么Oauth2中,会不会还保存SecurityContext在Session中呢?答案是否定的,因为 ResourceServer自动配置了NullSecurityContextRepository存储类,也就是不会保存Session了,只会将SecurityContext设置到线程中,方便后续逻辑使用,也就是每次访问,都会去查询Redis中的会话信息。

猜你喜欢

转载自blog.csdn.net/qq_43437874/article/details/121277900