ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Cloud Security OAuth2令牌存储选型与RedisTokenStore实践

Spring Cloud Security OAuth2令牌存储选型与RedisTokenStore实践 大概是去年年中线上商城的用户突然开始集中反馈“登录没一会儿就得重新登录”同一个账号在手机上登录后另一台设备就掉线。一开始怀疑是会话超时后来把日志全部拉出来对比才发现问题出在Spring Cloud Security集群里的OAuth2令牌存储上——授权服务器把token默认存在内存里两台服务器各存各的用户请求被网关转发到另一台节点后自然校验不过。那次排查让我意识到令牌存储不是“随便找个地方塞一下”的简单事它在微服务安全体系里直接决定了token能不能被各节点稳定识别。这篇文章就围绕Spring Cloud Security下OAuth2令牌存储这个话题展开不走极端也不绕弯子。我会先讲清楚内存、数据库、Redis、JWT这几种存储方案的取舍逻辑再给一份可以直接改的RedisTokenStore配置然后聊JWT无状态令牌这条分支路线的真实价值最后分享几个我上线后才遇到的序列化和缓存坑。无论你是正在搭授权中心还是已经跑起来但时不时掉线这篇都值得花十分钟读完。1. 从Session共享聊起为什么令牌存储会决定微服务安全成败1.1 InMemoryTokenStore的隐患Spring Security OAuth2的默认TokenStore是InMemoryTokenStoretoken全部存在当前进程的内存Map里。单机环境下所有请求由同一个进程处理token自然找得到。但微服务架构里网关、授权服务器、资源服务器通常是独立部署甚至授权服务器本身就有多个实例负载均衡会把不同请求分发到不同节点。用户拿着token来访问如果落到没有存储该token的节点服务端就认为token无效直接返回401。我那次线上事故就是这样授权服务器两个节点A节点签发的token只存在AB节点收到请求后查不到用户就被强制重新登录。InMemoryTokenStore还带来一个运维盲区重启即丢失。每次发布授权服务所有在线的token清零在用户侧的体验就是“你们发版本我就掉线一次”。这在灰度发布场景里尤其明显哪怕只重启一台节点也有部分用户受影响。1.2 JdbcTokenStore的场景与代价内存不行自然会想往数据库里放。JdbcTokenStore可以把token持久化到oauth_access_token、oauth_refresh_token这些表里重启不丢集群之间也能通过数据库共享。表结构是框架定义好的网上可以找到对应的schema脚本初始化成本不高。但JdbcTokenStore的问题也很直接每次校验token都要查一次数据库。授权服务本身就是高频调用token校验又是一次请求至少查询一次的硬依赖数据库连接数和IO压力会成倍上涨。我们压测的时候连接池一度被打满慢SQL全是oauth_access_token的查询。所以数据库方案更适合中小系统、并发量几百的规模不适合作为微服务安全的基础设施。1.3 RedisTokenStore成为主流方案的原因RedisTokenStore同时解决了共享和性能两个问题。token写进Redis多个授权服务实例连同一个Redis集群任何节点都能查到tokenRedis的内存性能比数据库高一个量级并且Redis自身支持过期策略access_token的2小时有效期可以直接映射到Redis TTL省去了手动清理过期数据的工作。它的key设计也有讲究access token本身、refresh token、client id到token的索引、username到token的索引都落在Redis里方便按用户维度剔除异常会话。下表是我在项目里做的选型对比可以作为参考存储方案集群共享重启影响性能适用规模InMemoryTokenStore不支持token全部丢失最快单机演示/开发JdbcTokenStore支持不丢失数据库瓶颈中小系统低频访问RedisTokenStore支持不丢失高微服务集群主流JwtTokenStore天然无状态不丢失高跨服务共享、无需撤销如果你用的是Spring Authorization Server这类新框架TokenStore的抽象名会有些调整但选型逻辑完全一致。下面的实操还是以老牌spring-security-oauth2的配置方式来讲它依然是大量Spring Cloud技术栈项目在用的方式。2. RedisTokenStore配置实操从依赖到TokenServices一次走通2.1 引入依赖与Redis连接要让RedisTokenStore生效先确认pom里同时存在OAuth2 starter和Redis starter。我用的Spring Boot版本是2.3.x对应的Spring Cloud还是Hoxton版本依赖如下dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-oauth2/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencyRedis连接用Spring Boot的自动配置即可最基础的就是application.yml里配一下spring: redis: host: 10.0.0.15 port: 6379 password: your-redis-password database: 3 lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2这里有个细节值得提醒Redis的database一定要和业务缓存分开。我在一个项目中没注意token存到了默认db0业务缓存也跑在db0后来一次缓存清理操作直接把所有token删光了。用独立database或者干脆用独立的Redis实例给token一个干净的存储空间。2.2 授权服务器的完整配置接下来看授权服务器配置。这里需要做四件事声明TokenStore的Bean、配置ClientDetailsService、把TokenStore和TokenServices注入AuthorizationServerEndpointsConfigurer。Configuration EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { Autowired private AuthenticationManager authenticationManager; Autowired private RedisConnectionFactory redisConnectionFactory; Autowired private PasswordEncoder passwordEncoder; Bean public TokenStore tokenStore() { return new RedisTokenStore(redisConnectionFactory); } Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(client-app) .secret(passwordEncoder.encode(client-app-secret)) .authorizedGrantTypes(password, refresh_token) .scopes(read, write) .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(604800); } Bean public DefaultTokenServices tokenServices() { DefaultTokenServices tokenServices new DefaultTokenServices(); tokenServices.setTokenStore(tokenStore()); tokenServices.setSupportRefreshToken(true); tokenServices.setReuseRefreshToken(true); tokenServices.setClientDetailsService(clientDetailsService()); tokenServices.setAccessTokenValiditySeconds(7200); tokenServices.setRefreshTokenValiditySeconds(604800); return tokenServices; } Bean public ClientDetailsService clientDetailsService() throws Exception { return new InMemoryClientDetailsServiceBuilder() .withClient(client-app) .secret(passwordEncoder.encode(client-app-secret)) .authorizedGrantTypes(password, refresh_token) .scopes(read, write) .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(604800) .and() .build(); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authenticationManager(authenticationManager) .tokenStore(tokenStore()) .tokenServices(tokenServices()); } Override public void configure(AuthorizationServerSecurityConfigurer security) { security.tokenKeyAccess(permitAll()) .checkTokenAccess(isAuthenticated()) .passwordEncoder(passwordEncoder); } }配置类拆成两部分是刻意的configure(ClientDetailsServiceConfigurer clients)负责OAuth2客户端信息tokenServices()里的clientDetailsService()负责refresh_token刷新时读取客户端配置。两者共用同一个InMemoryClientDetailsServiceBuilder保证刷新token时能正确识别客户端。2.3 TokenServices与TokenStore的关系很多初学者只配置了TokenStore忽略了TokenServices结果发现refresh_token不生效或者过期时间不按自己设置的走。原因在于AuthorizationServerEndpointsConfigurer内部会创建DefaultTokenServices但这个默认对象的个性化配置能力很弱。建议像上面一样显式声明DefaultTokenServices的Bean把tokenStore、token过期时间、是否支持refresh_token全部塞进去再通过.tokenServices(tokenServices())交给endpoints。值得注意的还有setReuseRefreshToken(true)。这个参数决定refresh_token刷新后是否复用。true表示刷新后拿到的还是同一个refresh_token客户端实现简单false表示每次刷新都生成全新的refresh_token旧refresh_token立刻失效安全性更高但客户端必须能够更新并保存新的refresh_token。我生产环境习惯设为false降低refresh_token泄露后长期可用的风险。如果有客户端不会更新refresh_token就只能保持true。3. JWT令牌路线无状态存储的收益与隐藏代价3.1 JwtTokenStore到底“存”了什么RedisTokenStore解决了共享问题但每次校验token都要访问Redis跨服务时还要通过网络查一次。于是另一种思路出现了把用户信息直接编码进token本身服务端不需要“存储”解析token就能验证身份。这就是JWT。在Spring Security OAuth2里JWT对应的TokenStore是JwtTokenStore。它其实不真正存储token只负责把JWT字符串解析成OAuth2AccessToken对象。你注册JwtTokenStore后授权服务器签发token时会用JwtAccessTokenConverter把用户信息、角色、scope都编码进JWT资源服务器拿到JWT后通过相同的Secret或公钥验签不需要反向调用授权服务器。3.2 JwtAccessTokenConverter配置签名JwtAccessTokenConverter是这条链路的核心。最简单的配置方式是使用对称密钥Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setSigningKey(your-jwt-secret-key); return converter; }所有服务使用同一个SigningKey配置简单但一旦密钥泄露攻击者就能自己伪造合法token。我在项目里用的是非对称密钥授权服务器用私钥签名资源服务器用公钥验签。私钥只存在于授权服务器公钥可以安全地分发给资源服务。生成密钥对可以用JDK自带的keytoolkeytool -genkeypair -alias jwtsign -keyalg RSA -keysize 2048 -validity 3650 -keystore jwt.jks然后在配置里加载Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); KeyStoreKeyFactory keyStoreKeyFactory new KeyStoreKeyFactory( new ClassPathResource(jwt.jks), store-password.toCharArray()); converter.setKeyPair(keyStoreKeyFactory.getKeyPair(jwtsign)); return converter; }使用JWT无状态方案后资源服务器不再需要每请求查Redis性能确实更好。但它也有明显的死角。3.3 无状态token撤销与窃取风险JWT一旦签发在有效期内无法在服务端主动失效。用户的权限发生变化、用户被禁用、用户主动登出token仍然有效。这在微服务权限管理里是个大问题。我见过一些项目为了加黑名单Redis里存所有JWT的jti其实又回到了“有状态”的老路只是token从数据库搬到了Redis。还有一点容易被忽略JWT默认只有签名没有加密payload里的用户信息是明文。如果把手机号、角色、甚至会议邀请记录写进自定义claims任何拿到token的人Base64解码就能看到。所以JWT里的claims一定要克制只放不敏感的信息如user_id、scope、client_id。JWT方案更适合那些对外提供API且各服务不共享存储的场景比如开放平台。内部微服务系统如果权限多变、需要即时撤销我更推荐RedisTokenStore。没有绝对优劣只有取舍匹配。4. 刷新令牌与多实例共享长会话不再掉线的关键4.1 让refresh_token真正工作起来access_token有效期通常只有2小时客户端要在token过期后用refresh_token换取新的access_token。这一步能否成功取决于三处配置必须一致第一Clients配置里的authorizedGrantTypes需要包含refresh_token第二TokenServices里setSupportRefreshToken(true)必须开启第三请求token接口时带上grant_typerefresh_token。正常情况下刷新请求长这样POST /oauth/token Authorization: Basic client-app:client-app-secret Content-Type: application/x-www-form-urlencoded grant_typerefresh_tokenrefresh_tokenYOUR_REFRESH_TOKEN刷新成功后服务端会返回新的access_token。如果一切配置正确但一直报Invalid refresh token大概率是TokenStore没有共享或者refresh_token本身超过了有效期。我们排障时比较容易忽略的是DefaultTokenServices的setRefreshTokenValiditySeconds要和Clients配置里的refreshTokenValiditySeconds一致否则以刷新请求发起时间计算的有效期会和你预期不符。4.2 多实例下的TokenStore共享前面说了内存存储导致多实例掉线。换成RedisTokenStore后所有授权服务器节点连同一个Redistoken的写入和读取都在同一处。这里有一个容易出的昏招为了“提速”在AuthorizationServerEndpointsConfigurer里自行缓存token。我见过同事在TokenStore外面又包了一层本地缓存登录时缓存一份校验时先查本地缓存再查Redis结果某次刷新token后本地缓存没有同步新旧token并存权限判断错乱。要记住RedisTokenStore本身就是为共享存储设计的不要在它外面再包一层本地缓存。如果担心Redis访问延迟先检查连接池参数和序列化方式而不是牺牲一致性。另外资源服务器校验token时通常会调用授权服务器的/oauth/check_token接口。如果资源服务器和授权服务器之间网络不稳定这个调用就是新的瓶颈。一个折中做法是资源服务器也配置同一个TokenStore直接在本服务里校验token但要注意Redis的连接池配置要够用多个服务同时连Redis连接数会增长得很快。4.3 token清理与过期策略Redis的TTL会自动清理过期token但OAuth2的存储结构里auth_to_access这种索引不一定有严格TTL。长时间运行后Redis里可能残留大量无用的索引key。我习惯写一个定时任务每天扫描并清理超时的index key。定时清理的参考实现思路通过redis.keys(auth_to_access:*)这类模式查询或者使用SCAN命令避免阻塞Redis再根据索引对应的token是否还有效来决定是否删除。实际部署时如果Redis集群足够健壮这些清理可以放在业务低峰期执行避免高峰期扫描影响性能。5. 实战排坑序列化、缓存、登出与权限残留5.1 Redis反序列化导致token查不到的经典坑spring-data-redis默认使用JDK序列化存进Redis的对象会变成一串以\xAC\xED开头的乱码。如果RedisTokenStore在写入时使用一种序列化器而你的运维脚本、管理后台或另一个服务用JSON序列化器去读取会出现“明明Redis里有key但业务查不到token”的诡异问题。我踩过最经典的一次坑授权服务可以正常发token资源服务却一直提示invalid token。用Redis客户端翻data里确实有token相关key但key不规整带有二进制前缀。原因就是RedisConnectionFactory默认序列化方式不一致导致RedisTokenStore写入的token数据无法被资源服务的TokenStore正确反序列化。解决办法是统一Redis的序列化配置将RedisTemplate的key和value都设为String或JSON序列化但OAuth2的RedisTokenStore内部不是走RedisTemplate它直接基于RedisConnection操作字节。更要注意的是所有连同一个Redis的授权服务和资源服务必须使用一致的RedisConnectionFactory配置。不要在一个服务里自定义序列化导致字节格式偏离框架预期。我的建议是token专用的Redis连接保持默认序列化配置业务缓存单独建RedisTemplate并自定义序列化器两者从根上隔离。这样排错时只需要检查spring.redis.*配置是否一致。5.2 权限缓存与用户状态不一致另一个容易被忽略的问题是用户权限缓存。资源服务器往往会对用户的权限列表做本地缓存比如按user_id缓存角色集合。当用户从RedisTokenStore里带着token访问资源时服务端可能只校验token是否有效不去同步用户最新的角色变化。结果就是用户在后台改了权限token没过期之前旧权限一直生效。如果项目对权限实时性要求高token有效期就要设短一些比如30分钟配合refresh_token进行续期。这样最多只损失30分钟的一致性窗口。也可以在自定义UserDetailsService里每次从TokenStore的getAccessToken(auth)反查用户信息但这样每请求都查一次数据库或Redis性能上不划算。5.3 登出接口与token立即失效很多团队接OAuth2时只实现登录不实现真正的登出。用户点“退出”后前端把本地token删掉就以为结束了实际上token在Redis里还活着直到自然过期。这在小范围内还好说但涉及到账号安全时必须让登出真正生效。实现思路是自定义一个登出接口调用TokenStore.removeAccessToken和removeRefreshToken同时删除auth_to_access等相关索引。下面是一个简化的登出ControllerRestController public class LogoutController { Autowired private TokenStore tokenStore; DeleteMapping(/logout) public ResponseEntityVoid logout(HttpServletRequest request) { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String tokenValue authHeader.substring(7); OAuth2AccessToken accessToken tokenStore.readAccessToken(tokenValue); if (accessToken ! null) { tokenStore.removeAccessToken(accessToken); tokenStore.removeRefreshToken(accessToken.getRefreshToken()); } } return ResponseEntity.ok().build(); } }注意删除token前需要确保当前请求用户的身份已经验证这个接口要放在安全上下文中不能裸奔。还有一个细节如果使用JWTremoveAccessToken是不能让已签发的JWT失效的需要配合黑名单机制否则登出形同虚设。5.4 网关层单点校验与内层重复校验在Spring Cloud网关中很多做法是网关统一校验token校验通过后再把用户身份透传给下游服务。透传一般通过header完成下游服务再从token或header中获取用户ID。理想情况下下游服务不需要再访问Redis验token。实际项目里有些下游服务为了“保险”又自己加了一次TokenStore校验结果每个请求经过网关、下游服务都要查Redis吞吐量立刻掉下来。合理做法是网关层完成token校验并清理无效token下游服务只信任网关透传的身份头不在业务链路上重复校验。如果由于历史原因某些服务必须自己校验那就要确认这些服务的TokenStore配置和Redis连接参数完全一致并且做好Redis连接池扩容。我个人在最后上线前的体检中还会重点看三个指标授权服务内存是否有较大JVM增长、Redis中token key的增长速率是否匹配登录量、refresh_token是否频繁过期。这三个指标基本能反映令牌存储配置是否健康。关于令牌存储还有一个常见误解值得纠正很多人以为AccessToken和RefreshToken只是过期时间不同存法可以一样。但OAuth2里的RefreshToken一般只用来换AccessToken不能直接访问资源。在RedisTokenStore里refresh token和access token是两套数据设置过期时间时要分别定义否则可能出现access token都过期两轮了refresh token还在或者反过来refresh token过期导致长期在线用户被迫重登。我在维护项目时习惯把TokenStore选型当成一个“安全架构决策”而不是一行Bean配置。内存、数据库、Redis、JWT各有各的适用边界选型之前先想清楚多实例场景、token撤销需求和性能指标。如果从一开始就选对后面真的能少很多深夜排查的麻烦。
返回列表