资讯详情

Spring Security 7的OAuth2授权码Redis存储方案

📅 2026/10/11 9:09:15 | 华诺云谱 👁 阅读
Spring Security 7的OAuth2授权码Redis存储方案
写这篇文章的时候我正好在帮团队把一套基于Spring Boot 3的认证中心从单机内存模式改造成支持多实例部署的分布式架构。折腾了整整一个下午踩了不少坑最终敲定了Spring Security 7框架下OAuth2授权码走Redis存储的方案。先简单交代一下背景新来的同事把授权服务器部署了两台结果用户在第一台机器上完成登录请求却被负载均衡转到了第二台授权码直接找不到了。这个问题的根源就在于授权码默认存在本地内存里多实例根本不共享。如果你也在做类似改造这篇文章就是写给你看的。1. 为什么授权码一定要“挪窝”到Redis授权码Authorization Code这个东西本质上是OAuth2授权码模式里的一次性临时凭证作用时间极短一般也就60秒到10分钟。但它恰恰是整个认证链路里最容易被忽略的状态数据。1.1 内存存储的“单机魔咒”Spring Security自带的授权服务器实现在没有额外配置时OAuth2AuthorizationService默认使用InMemoryOAuth2AuthorizationService。这个实现内部就是一个ConcurrentHashMap把授权码、访问令牌、刷新令牌全部堆在当前进程的堆内存里。单机部署没问题一旦上Nginx负载均衡或者Kubernetes多副本问题立刻暴露。用户访问/oauth2/authorize端点时请求落在实例A上授权码保存在A的内存里。紧接着前端拿着授权码去换令牌请求被负载均衡转发到实例BB的内存里什么都没有直接抛出InvalidGrantException。我见过不少团队在这个问题上卡住第一反应是“把负载均衡改成IP哈希让同一个用户的请求固定落到同一台机器”。这种方法看似能解决但治标不治本实例重启、扩容缩容、灰度发布都会导致会话漂移授权码照样丢失。1.2 JDBC存储救不了分布式Spring Security官方还提供了基于JDBC的JdbcOAuth2AuthorizationService把授权码、令牌序列化后存进数据库表oauth2_authorization。这种方式能解决多实例共享问题数据库是天然的共享存储。但JDBC方案有两个让我头疼的地方读写延迟。授权码交换令牌的链路本身要求低延迟每次都要序列化一个巨大的Java对象再写进数据库在登录峰值时容易成为瓶颈。需要额外维护数据库表结构。官方schema文件里那张表字段非常多光索引就要建好几个对于只需要授权码功能的小团队来说有点重。Redis在这两个维度上正好击中痛点读写快内存操作微秒级自然支持TTL过期授权码本身就有时效性交给Redis的过期机制管理连定时清理的代码都不用写。1.3 Redis在这条链路里的准确定位把Redis引入OAuth2授权码链路不是取代授权服务器而是起到“状态暂存区”的作用。授权码在Redis里生命周期很短过期即删不承载任何永久性数据所以根本不用担心Redis宕机导致用户数据丢失——最多就是认证过程中的临时状态没了让用户重新登录一次而已。理解了这个定位后面做技术选型、写代码的时候就有了分寸不能把所有认证相关状态都往Redis里塞只存授权码和短暂令牌其他东西该放数据库放数据库。2. 整体方案设计与Redis数据结构规划明确目标后我先在纸上画了一下数据流授权请求进来授权服务器生成授权码写入Redis用户带着授权码去令牌端点换令牌从Redis取出来校验校验通过后删除Redis里的授权码生成访问令牌。整个过程的关键就是那一份授权码要到Redis里“住上几十秒”。2.1 核心接口OAuth2AuthorizationServiceSpring Security的授权服务器有一张“万能网”所有关于授权码、令牌的存取操作都收口在OAuth2AuthorizationService接口里。这个接口定义了三个方法save(OAuth2Authorization authorization)保存或更新授权信息remove(OAuth2Authorization authorization)删除授权信息findById(String id)按ID查询findByToken(String token, TokenType tokenType)按令牌值查询授权码、访问令牌、刷新令牌都走这个方法我之前看到网上一些文章一上来就教你写RedisOAuth2AuthorizationService实现类但很少有人讲清楚这个接口背后的调用时机。save方法在授权码生成和令牌刷新时都会调用findByToken在拿授权码换令牌时调用。理解这些触发点你才会知道Redis的key该怎么设计过期时间该设多少。2.2 Redis Key设计不搞花活直接拼IDRedis的key设计我走了弯路。一开始想用授权码作为key方便按码值查询后来发现findByToken方法里传入的token未必是授权码也可能是访问令牌或刷新令牌。如果只用一种规则存key三种令牌类型会互相覆盖。Spring的默认JDBC实现有一个值得借鉴的思路独立存储按类型区分。我最终采用了这样的key设计spring:oauth2:authorization:{id}存储授权对象ID是OAuth2Authorization.getId()spring:oauth2:authorization:{tokenType}:{tokenValue}存储令牌索引tokenType是code、access_token、refresh_token为什么要存两份因为findByToken拿到的是用户提供的授权码值但授权对象的主键是id两者对不上。Redis查询不像数据库那样能用WHERE token_value ?来检索必须通过索引key先映射到授权记录ID再查出完整对象。空间换时间这是Redis场景的标准做法。2.3 过期时间跟随授权码生命周期TTL的设计直接决定这个方案稳不稳。授权码默认有效期是5分钟我在RegisteredClient里配置的是AuthorizationCode有效期300秒。Redis的key过期时间我设置成600秒留了一倍的缓冲防止网络抖动导致授权码在有效期内却已经被Redis清掉。访问令牌默认有效期是30分钟refresh token是7天这些也要分别设置对应的TTL。我的做法是写一个辅助方法getTokenTimeout(TokenType tokenType)根据令牌类型返回不同的过期秒数。有一点必须提醒Redis的TTL只是兜底真正的业务过期校验仍由AuthorizationCodeAuthenticationProvider按照RegisteredClient里配置的时效来判断。也就是说Redis里的数据即使没过期业务层面判断授权码已超时照样拒绝。TTL设长一点只是为了安全删除不改变业务语义。3. 核心代码落地Redis存储OAuth2Authorization看官方文档的人会注意到Spring Security 7里授权服务器相关的类做了不少调整基于Spring Boot 3项目配置方式简洁了很多。我用的是Spring Boot 3.x Spring Security 7正式发布版对应的是6.x的演进版本授权服务器的自动配置已经内置不需要再引入spring-security-oauth2-authorization-server独立依赖。3.1 引入依赖和Redis配置既然已经在Spring Boot项目里Redis的配置交给spring-boot-starter-data-redis就很简单了。加上连接池配置避免高并发下连接数不够用dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency配置文件里我建议把连接池和过期策略一起写上spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword timeout: 3000ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000ms这里有个细节spring.data.redis.*是Spring Boot 3.0以后的配置前缀老项目里常见的spring.redis.*写法在Spring Boot 3里已经废弃。许多人升级后Redis一直连不上十有八九就是这个坑。3.2 序列化策略别把SimpleGrantedAuthority烤糊了直接存OAuth2Authorization对象会遇到一个大麻烦这个对象内部包含Authentication、Principal、GrantedAuthority等复杂类型普通的GenericJackson2JsonRedisSerializer序列化时会报类型转换错误或者反序列化后变成LinkedHashMap。我建议直接使用Jackson的多态序列化方式。在RedisConfig里配置ObjectMapper时把需要序列化的具体类型注册进去Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); ObjectMapper objectMapper new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(objectMapper); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }LaissezFaireSubTypeValidator是关键它允许反序列化时恢复原始类型信息。不过有一点要说明多态序列化会把类完整路径写进Redis比如com.example.security.authentication.UsernamePasswordAuthenticationToken这不是安全问题因为反序列化只接受这个Jackson实例里注册过的类型。对于SimpleGrantedAuthority这种内部类反序列化时会有些小毛刺建议在自定义授权服务器配置里把权限对象转换成自定义的GrantedAuthority实现避免直接用Spring内部类。3.3 实现OAuth2AuthorizationService这是整个方案的核心类。我看过网上很多实现有的只实现save和findByToken却忘了remove导致授权码在Redis里变成僵尸数据。我的实现里把三个方法都覆盖了Service public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService { private final RedisTemplateString, Object redisTemplate; private static final String NAMESPACE spring:oauth2:authorization; public RedisOAuth2AuthorizationService(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } Override public void save(OAuth2Authorization authorization) { String id authorization.getId(); String key buildKey(id); redisTemplate.opsForValue().set(key, authorization, getTimeout(authorization), TimeUnit.SECONDS); // 保存各个令牌的索引方便按token值查询 for (OAuth2Authorization.TokenOAuth2AuthorizationCode code : authorization.getTokens(OAuth2AuthorizationCode.class)) { if (code.getToken().isActive()) { String indexKey buildTokenIndexKey(code, code.getToken().getTokenValue()); redisTemplate.opsForValue().set(indexKey, id, getTimeout(authorization), TimeUnit.SECONDS); } } // 访问令牌和刷新令牌索引逻辑类似此处省略重复代码 } Override public void remove(OAuth2Authorization authorization) { if (authorization null) return; redisTemplate.delete(buildKey(authorization.getId())); // 同时清理所有令牌索引这里需要在保存时维护token到id的映射 } Override public OAuth2Authorization findById(String id) { return (OAuth2Authorization) redisTemplate.opsForValue().get(buildKey(id)); } Override public OAuth2Authorization findByToken(String token, OAuth2Authorization.TokenType tokenType) { String tokenKey buildTokenIndexKey(tokenType.getValue().toLowerCase(), token); Object id redisTemplate.opsForValue().get(tokenKey); if (id null) return null; return findById((String) id); } private String buildKey(String id) { return NAMESPACE : id; } private String buildTokenIndexKey(String type, String tokenValue) { return NAMESPACE : type : tokenValue; } private long getTimeout(OAuth2Authorization authorization) { // 根据授权对象里的令牌类型设置不同TTL简化起见统一返回600 return 600L; } }有一个细节必须强调findByToken方法传进来的TokenType是OAuth2Authorization.TokenType枚举访问令牌的类型值小写是access_token刷新令牌是refresh_token授权码是code。拼索引key时如果大小写没处理好会找不到预存的索引。remove方法也要实现完整。在实际调用链中授权码换令牌成功后Spring会调用remove把授权码清掉。如果不清同一个授权码理论上可以重复使用——虽然授权服务器内部校验会拦截但Redis里残留的无用数据会越积越多白白浪费内存。3.4 替换默认实现注入自己的Service代码写完还要让授权服务器用它。在Spring Security 7的授权服务器配置里OAuth2AuthorizationService是一个Bean框架自动注入默认的内存实现。我只需要在配置类里显式声明自己的RedisOAuth2AuthorizationService并且把它放进OAuth2AuthorizationServerConfigurerConfiguration EnableWebSecurity public class SecurityConfig { private final RedisOAuth2AuthorizationService authorizationService; public SecurityConfig(RedisOAuth2AuthorizationService authorizationService) { this.authorizationService authorizationService; } Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(/oauth2/**, /login, /authorize) .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())) .authorizeHttpRequests(authorize - authorize.anyRequest().authenticated()) .with(new OAuth2AuthorizationServerConfigurer(), configurer - { configurer.authorizationService(authorizationService); }) .csrf(csrf - csrf.ignoringRequestMatchers( new AntPathRequestMatcher(/oauth2/token), new AntPathRequestMatcher(/oauth2/revoke) )); return http.build(); } }把authorizationService(authorizationService)这段去掉系统就默认用内存实现加上之后授权码就切到Redis了。切换成本极低这也是Spring Security抽象做得好的地方。4. 实操中遇到的坑序列化、并发与过期清理代码落地只是第一步真正折磨人的是上线后遇到的问题。我把自己踩过的坑整理出来按严重程度排个序。有些问题不踩不知道一踩就足以让整个认证中心“半身不遂”。4.1 授权码序列化导致的“反序列化崩溃”这个坑花了我最多时间。授权码存入Redis后拿回来一对比发现authorization.getAttributes()里的AuthorizationRequest对象变成了空壳客户端ID丢失。根本原因就是没有把AuthorizationRequest相关的类注册到Jackson的ObjectMapper里。解决方案有两种。一种是给ObjectMapper配置activateDefaultTyping让所有非final类型存进去时带上类信息另一种是只对已知类型开启多态。我推荐第二种安全性和稳定性更高objectMapper.registerModule(new SimpleModule().addAbstractTypeMapping( AbstractAuthenticationToken.class, UsernamePasswordAuthenticationToken.class ));如果你只是自己测试还好一旦授权服务器对接了多个客户端应用每个客户端的AuthorizationRequest里带的GrantedAuthority实现类可能都不一样反序列化时直接报InvalidDefinitionException。这个问题的通用解法是在RedisConfig里用setObjectMapper定制序列化器把常用的安全相关的类型都显式注册一遍。4.2 Redis连接超时拖垮授权码交换授权码交换令牌的接口是/oauth2/token这个接口对性能极其敏感。有一次我压测时发现并发200的时候Redis连接池被打满JedisConnectionException不断抛出认证直接失败。排查后发现是lettuce连接池配置问题。默认的max-active是8对于授权码这种高频短连接场景完全不够用。另外timeout: 3000ms只设置了读取超时但连接池获取连接时的max-wait如果设得太短高并发下会出现大量pool exhausted异常。建议把连接池参数设置成我上面列的那组值同时开启lettuce的shareNativeConnectionSpring Boot默认开启让多个操作复用同一个连接。对于极端峰值场景还可以在Redis客户端侧做熔断降级授权码查询失败时直接返回认证失败让用户重试而不是无限阻塞。4.3 Redis宕机后授权服务器还能不能用这个问题在架构评审时被同事反复追问。我的结论是授权服务器可以设计成“降级模式”但默认要走向快速失败。为什么不能默认降级因为授权码状态一旦丢失继续放行请求会导致严重的安全漏洞——用户明明没有完成授权却能换到令牌。这种情况下快速失败是唯一正确的选择。不过有一种降级思路可以考虑在Redis不可用时把OAuth2AuthorizationService切换到InMemoryOAuth2AuthorizationService牺牲分布式一致性换取单机可用性。前提是你得接受一个事实多实例之间授权码不共享负载均衡策略需要临时调整为IP哈希。这个方案我只建议在非核心环境比如预发环境用生产环境别玩这个。4.4 授权码过期在Redis里还存在怎么清理前面提到Redis的TTL和业务过期时间是两套机制。业务判断授权码过期后会返回InvalidGrantException但Redis里的数据要等TTL到了才会真正删除。这期间数据占着内存。我补充了一个兜底清理策略在RedisOAuth2AuthorizationService里加一个定时任务每5分钟扫描一次授权码索引检查AuthorizationCode.getExpiresAt()如果已过期但仍在Redis中主动删除Scheduled(cron 0 */5 * * * ?) public void cleanExpiredAuthorizations() { // 扫描所有以 spring:oauth2:authorization:code: 开头的key // 逐一取出ID比对过期时间过期则执行删除 }不过这里要特别小心直接使用keys命令在生产环境是灾难。Redis的keys在大数据量下会阻塞单线程必须用scan命令迭代。我写了个ScanOptions版本的批量清理工具实测在生产环境运行稳定没有阻塞Redis。5. 关键配置与安全加固建议方案运行稳定后我又从安全角度过了几遍设计。很多团队只盯着功能实现忘了授权码存储这个环节本身也是有安全要求的。5.1 Redis本身要足够安全授权码虽然不是持久凭证但它是换取令牌的“钥匙”。如果Redis被未授权访问攻击者可以读取授权码再配合拦截到的请求就能冒领访问令牌。因此线上环境的Redis至少要满足这几点启用密码认证禁用config命令绑定内网IP不暴露公网端口。用云厂商的Redis服务时要开启虚拟私有网络隔离。Redis的protected-mode必须设置为yes。另外推荐开启Redis的AOF持久化。有人会觉得授权码丢了就丢了没必要开AOF但考虑一个场景授权码刚写入Redis服务端还没来得及完成响应Redis进程崩溃。如果开启了AOF且刷盘策略合理重启后至少不丢最近一秒的数据对用户体验影响很小。5.2 密钥和客户端凭据不要进Redis我做第一个版本的时候图省事把RegisteredClient里的clientSecret也一起放进了Redis缓存。后来安全审查发现这个问题客户端密钥属于静态凭证不应该出现在缓存系统里。正确的做法是Redis里只放OAuth2Authorization对象即跟授权会话有关的动态数据。RegisteredClient相关的配置继续放在数据库或者内存配置里让RegisteredClientRepository去管理。两者职责分离安全边界清晰。5.3 审计日志与监控指标上线稳定之后我加了两样东西审计日志和Redis监控指标。审计日志记录每次授权码生成、消费、删除的关键节点包括客户端ID、用户主体、来源IP和授权码指纹MD5哈希不能存原文。Redis监控指标包括授权码存量、命中率、过期清理数量、Redis读写耗时P99。这些数据接入Prometheus后我能很直观地看到授权码链路的健康度。有一次发现授权码存量曲线异常上涨排查后发现是一个客户端在循环刷新授权码没有及时消费是客户端代码逻辑问题。如果没有监控指标这种问题可能要等用户投诉了才会暴露。6. 常见问题速查表把整个改造过程中积攒的问题整理成一个速查表方便大家对照排查。简单列一下主要的几个典型问题症状可能原因解决办法授权码交换令牌时返回invalid_grant多实例部署但授权码仍存内存确认是否注入了RedisOAuth2AuthorizationService查看Redis key是否存在Redis key存在但反序列化报错Jackson没有注册安全相关的类型配置ObjectMapper的多态序列化显式注册抽象类实现上线后授权码存储速度明显变慢Redis连接池配置不合理或网络延迟高调大max-active开启lettuce连接复用检查同机房部署压测时授权码大量丢失Redis主从切换导致写入丢失开启AOF持久化保证写入多数节点或使用云Redis的高可用版本授权码成功消费后Redis里还有残留remove方法没实现完整在save和remove里同时维护索引key和主key的清理逻辑还有一个大家问得比较多的为什么我不直接用RedissonClient做分布式锁授权码存储本身是读写Redis不需要分布式锁。真正需要加锁的场景是“同一个授权码同时被两个请求消费”在Redis层面可以用set(key, value, nx)做幂等控制但这超出本文范围。授权码消费本身足够快并发冲突概率极低建议先把基础方案跑通再加锁优化。从内存存储切到Redis存储核心改动就是实现一个OAuth2AuthorizationService并替换默认Bean但这个看似简单的替换牵扯到序列化、TTL、索引、异常兜底等一系列细节。我在实施过程中最大的体会是Spring Security 7的授权服务器框架已经把边界划得很清晰你自己要操心的不是框架内部怎么做而是数据持久层的选型适配。Redis作为授权码的临时存储介质既要利用它的高速读写也要警惕它“重开即空”的特点该设置的过期时间、监控、安全加固一项都不能少。如果后续你还要把访问令牌的JWT签名密钥也纳入统一管理这套Redis存储方案完全能复用把索引key再扩展一层即可。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑