SpringBoot整合Shiro与Redis实现JWT自动刷新方案
简介面向 Java 后端开发者的 Spring Boot 安全认证整合示例围绕 JWT、Shiro 与 Redis 三者协作解决无状态登录认证、权限控制以及令牌自动续期的完整技术链路。压缩包共 32 个文件其中 27 个 Java 源码文件为核心代码XML 与 YAML 文件提供框架及运行配置SQL 脚本用于初始化数据表整体仅 38KB规模轻量、结构清晰便于快速定位和提取所需代码。示例不仅完整实现 JWT 签发、解析和签名校验还通过自定义 Shiro 领域对象承接令牌认证逻辑将有效令牌与刷新令牌存入 Redis并设置了对应的过期策略当访问令牌接近失效时系统会自动生成新令牌并安全返回给客户端使用户无需重新登录即可持续访问受保护接口。配套过滤器会在每次请求时检查令牌剩余有效期临期时自动触发刷新流程整个过程对调用方完全透明兼顾了安全性与使用体验。资源已有 6591 人学习下载适合需要掌握 Spring Boot 安全框架组合应用、希望在项目中落地令牌无感刷新的中高级开发人员参考学习。1. 纯 JWT 方案撑不住真实业务token 过期与自动刷新到底在解决什么凌晨 2 点用户在手机上连续操作接口突然返回 401前端直接弹回登录页这一晚的会话记录全丢了。这是纯 JWT 方案最常见的翻车现场token 过期不可续过期时间调短了体验崩调长了泄露风险又大。SpringBoot 整合 JWT、Shiro、Redis 做 token 自动刷新本质是把「无状态令牌」和「服务端可控」这两件事同时做掉Shiro 负责认证授权入口JWT 负责携带身份Redis 负责存 refreshToken 与黑名单让过期令牌在用户无感知的情况下完成续期。适合正在做 App 会话保持、单点登录改造或者被「token 过期重登」投诉缠身的后端团队。2. 双 token 架构与 Redis 存储设计先定数据流再写代码很多项目一开始只用一个 accessToken过期时间设成 7 天换来的是「用户不用频繁登录」的假象代价是 token 一旦泄露攻击者能用一个长期有效的令牌进系统而你连主动踢掉它的手段都没有。自动刷新方案的核心思路是拆分短效的 accessToken 负责每次请求的认证长效的 refreshToken 只负责换新Redis 则记录 refreshToken 的活性状态让服务端重新获得「主动失效」的能力。2.1 accessToken 与 refreshToken 的分工为什么不能只用一个 tokenaccessToken 的生命周期通常控制在 1~2 小时它被放在每次请求的 Header 里暴露面最大所以有效期必须短。refreshToken 的有效期可以是 7 天甚至更长但它不应该出现在每次请求里只在需要续期时由客户端发给服务端且必须保存在客户端的安全存储中。这两个 token 各司其职缺一不可。只用短 token用户每两小时就要重新登录只用长 token相当于把安全全部押在单一令牌上。双 token 的真正价值在于即使 accessToken 在传输中被截获攻击者利用它的窗口只有一两个小时而 refreshToken 一旦发现异常可以直接从 Redis 删除让所有依赖它的续期请求立即失效。2.2 Redis 里存什么refreshToken 的 key 设计与过期策略这一环节最容易被做成「什么都往 Redis 塞」。我一般只维护三类键其余数据能不放就不放存储对象key 规则valueTTLrefreshToken 活性jwt:refresh:{jti}userId与 refreshToken 有效期一致7 天accessToken 黑名单jwt:blacklist:{jti}1accessToken 剩余有效时间刷新并发锁lock:refresh:{jti}110 秒短锁jti 是 JWT 标准里的唯一 ID每次签发 token 时用 UUID 生成天然满足多端登录场景。同一个用户可以同时持有多个 refreshToken各自对应不同的 jti互不影响。黑名单的 TTL 必须按 accessToken 的剩余有效期设置目的是让注销后的旧 token 在自然过期前一直处于拉黑状态。这套设计的要点在于JWT 本身只负责携带身份Redis 才是判断 token 是否仍然有效的事实来源。签名验证通过不代表令牌可用还要查一次黑名单和 refreshToken 活性。2.3 项目依赖与基础配置注意 Spring Boot 2.6 的路径匹配坑依赖选型上我踩过几次坑当前稳定组合是 shiro-spring-boot-starter 1.13.0 配合 jjwt 0.11.5Redis 直接用 Spring 官方 starter。shiro 1.10 以下版本对 Spring Boot 2.6 的兼容不够好尤其是路径匹配策略和过滤链注册顺序的问题后续避坑章会具体展开。dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring-boot-starter/artifactId version1.13.0/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependencyjjwt 从 0.11.x 开始把 API、实现、序列化拆成三个包缺一个启动时就会报 ClassNotFound。scope 为 runtime 的两个包只参与运行编译期用不到。接下来是配置文件这里有一个关键参数必须提前确认spring: redis: host: localhost port: 6379 mvc: pathmatch: matching-strategy: ant_path_matcher jwt: secret: aGVsbG8td29ybGQtdGhpcy1pcy1hLXRlc3Qtc2VjcmV0LWtleS0xMjM0NTY access-expire-ms: 7200000 refresh-expire-ms: 604800000spring.mvc.pathmatch.matching-strategy这一项是 Spring Boot 2.6 必须显式指定的否则 shiro 的 AntPathMatcher 和 Spring MVC 的 PathPatternParser 不一致过滤链里写的/login匹配规则会直接失效。JWT 的密钥是 Base64 编码后的字符串HS256 算法要求密钥至少 32 字节后面避坑章会专门讲密钥长度问题。3. 用 Shiro 接住 JWT自定义 Realm 与无状态过滤器改造Shiro 默认的认证流程是表单登录、Session 存储这些和 JWT 的无状态模型天然冲突。改造的方向是保留 Shiro 的过滤链和注解鉴权能力把认证入口从「用户名密码」换成「JWT 字符串」同时关掉 Session 存储让每次请求都从 token 还原用户身份。3.1 自定义 JwtToken 与 JwtFilter请求头到 Shiro Subject 的入口Shiro 的Subject.login(token)需要一个AuthenticationToken实现类。JWT 场景下这个类非常简单直接包装原始 token 字符串public class JwtToken implements AuthenticationToken { private final String token; public JwtToken(String token) { this.token token; } Override public Object getPrincipal() { return token; } Override public Object getCredentials() { return token; } }getPrincipal 和 getCredentials 都返回原始 token因为后续 Realm 只需要从字符串里解析 Claims不需要像密码认证那样区分「身份」和「凭证」。如果未来要把 refreshToken 也纳入同一个过滤器可以在该类里增加第二个字段。过滤器继承AccessControlFilter这是 Shiro 里最灵活的扩展点public class JwtFilter extends AccessControlFilter { private TokenService tokenService; Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { return false; } Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest (HttpServletRequest) request; String authorization httpRequest.getHeader(Authorization); if (authorization null || !authorization.startsWith(Bearer )) { writeJson(response, 401, 未携带 accessToken); return false; } String accessToken authorization.substring(7); try { getSubject(request, response).login(new JwtToken(accessToken)); return true; } catch (Exception e) { writeJson(response, 401, accessToken 无效或已过期); return false; } } }isAccessAllowed 直接返回 false意思是所有被该过滤器拦截的请求都走 onAccessDenied 分支统一在这里完成 token 校验。拿到 accessToken 后调用subject.login()Shiro 会把它交给 Realm 做认证。校验失败时向客户端返回 401不抛异常继续往下游传递避免污染业务层异常处理。3.2 自定义 JwtRealm只验 JWT不查数据库Realm 是 Shiro 的认证数据源改写它的核心是认证阶段只解析和验签 JWT不再查数据库。用户信息已经从 token 里还原出来了再查一次库纯属浪费。public class JwtRealm extends AuthorizingRealm { private TokenService tokenService; Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String jwt (String) token.getPrincipal(); Claims claims tokenService.parseAndValidate(jwt); String userId claims.getSubject(); if (tokenService.isBlacklisted(claims.getId())) { throw new AuthenticationException(accessToken 已注销); } return new SimpleAuthenticationInfo(userId, jwt, getName()); } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String userId (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.addRoles(permissionService.getRoles(userId)); info.addStringPermissions(permissionService.getPermissions(userId)); return info; } }supports 方法确保该 Realm 只处理 JwtToken 类型避免影响其他认证方式。认证阶段返回的 principal 是 userId这样在 Controller 里通过SecurityUtils.getSubject().getPrincipal()拿到的就是用户标识而不是一长串 JWT。授权阶段按 userId 加载角色和权限这个查询结果建议在 Redis 里做二级缓存避免每次请求都打数据库。3.3 Shiro 配置过滤链顺序与注解鉴权配置类负责把 Realm、过滤器、过滤链组装起来。这里最容易出问题的有两个地方过滤链的顺序必须用 LinkedHashMap 保证以及要显式关闭 Session 存储。Configuration public class ShiroConfig { Bean public JwtRealm jwtRealm() { return new JwtRealm(); } Bean public DefaultWebSecurityManager securityManager() { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(jwtRealm()); DefaultSubjectDAO subjectDAO new DefaultSubjectDAO(); DefaultSessionStorageEvaluator evaluator new DefaultSessionStorageEvaluator(); evaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(evaluator); manager.setSubjectDAO(subjectDAO); return manager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean factory new ShiroFilterFactoryBean(); factory.setSecurityManager(securityManager); factory.setLoginUrl(/unauthorized); MapString, Filter filters new HashMap(); filters.put(jwt, new JwtFilter()); factory.setFilters(filters); MapString, String filterChain new LinkedHashMap(); filterChain.put(/login, anon); filterChain.put(/logout, jwt); filterChain.put(/refresh, anon); filterChain.put(/static/**, anon); filterChain.put(/**, jwt); factory.setFilterChainDefinitionMap(filterChain); return factory; } }setSessionStorageEnabled(false)这行是关键它告诉 Shiro 不要为无状态接口创建 Session否则每次请求都会往 Redis 写一条 session 数据连接数和内存一起爆炸。过滤链里/login必须 anon 放行否则登录接口自己都会被 JWT 过滤器拦截/**放在最后表示兜底。RequiresPermissions注解默认不生效需要额外配置到 Spring 容器项目里放行业务特别多时要检查是否漏了这一步。4. 登录、黑名单与 token 自动刷新一条完整的会话链路从用户点击登录到会话结束整个过程可以分成三个动作登录时签发双 token注销时拉黑 accessToken 并删除 refreshToken请求过程中由过滤器判断是否需要自动续期。这一章把三个动作串成一条完整的链路。4.1 登录签发双 token把用户身份写进 JWT 的同时写入 Redis登录接口在 Shiro 之外单独实现用户密码校验成功后用 TokenService 生成两个 token并把 refreshToken 的活性状态写入 Redis。public class TokenService { private SecretKey key; private long accessExpireMs; private long refreshExpireMs; private RedisTemplateString, String redisTemplate; public String createAccessToken(String userId, ListString roles) { long now System.currentTimeMillis(); return Jwts.builder() .setId(UUID.randomUUID().toString()) .setSubject(userId) .claim(roles, roles) .setIssuedAt(new Date(now)) .setExpiration(new Date(now accessExpireMs)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String createRefreshToken(String userId) { long now System.currentTimeMillis(); String jti UUID.randomUUID().toString(); String refreshToken Jwts.builder() .setId(jti) .setSubject(userId) .setExpiration(new Date(now refreshExpireMs)) .signWith(key, SignatureAlgorithm.HS256) .compact(); redisTemplate.opsForValue().set( jwt:refresh: jti, userId, Duration.ofMillis(refreshExpireMs) ); return refreshToken; } }accessToken 里放入 roles 字段是为了避免每次接口鉴权都回源查角色权限变更时靠 Redis 清理缓存兜底。refreshToken 的 jti 是 Redis key 的一部分value 存 userIdTTL 和 refreshToken 过期时间一致。这里有一个容易被忽略的点Redis 的 TTL 和 JWT 的 exp 必须用同一个毫秒数否则会出现 refreshToken 还没到 JWT 里的过期时间Redis key 却已经被删掉的情况。4.2 注销与黑名单让 JWT 从「无状态」变成「服务端可控」JWT 最大的软肋是无法主动失效注销接口必须配合黑名单才能解决。核心思路是把 accessToken 的 jti 写进黑名单TTL 设成当前时间到 exp 的差值。public void logout(String accessToken) { Claims claims parseAndValidate(accessToken); long remaining claims.getExpiration().getTime() - System.currentTimeMillis(); if (remaining 0) { redisTemplate.opsForValue().set( jwt:blacklist: claims.getId(), 1, Duration.ofMillis(remaining) ); } // 删除该用户的所有 refreshToken实现全局下线 String userId claims.getSubject(); SetString keys redisTemplate.keys(jwt:refresh:*); for (String key : keys) { if (userId.equals(redisTemplate.opsForValue().get(key))) { redisTemplate.delete(key); } } }黑名单的 TTL 不能拍脑袋定如果随意设成 10 分钟token 自然过期前的这段时间就仍然有效。删除 refreshToken 用keys命令在 key 数量大时性能很差生产环境建议维护一个jwt:user:{userId}的集合记录该用户名下所有 refreshToken 的 jti注销时直接读取集合删除速度会快得多。4.3 自动刷新阈值与并发锁两个请求同时过期时只放行一个自动刷新的判断时机放在 JwtFilter 里每次请求进入认证之前先检查 accessToken 的剩余有效期。如果剩余时间低于设定的阈值就尝试用 refreshToken 换新。这里必须处理一个高并发场景两个请求同时到达同时发现 accessToken 即将过期如果都去刷新后发请求使用已被轮换掉的旧 refreshToken就会失败。public RefreshResult refresh(String refreshToken) { Claims claims; try { claims Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(refreshToken).getBody(); } catch (Exception e) { return RefreshResult.fail(refreshToken 无效); } String jti claims.getId(); String userId claims.getSubject(); String storedUserId redisTemplate.opsForValue().get(jwt:refresh: jti); if (!userId.equals(storedUserId)) { return RefreshResult.fail(refreshToken 已失效); } String lockKey lock:refresh: jti; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { return RefreshResult.retry(); } try { String doubleCheck redisTemplate.opsForValue().get(jwt:refresh: jti); if (!userId.equals(doubleCheck)) { return RefreshResult.fail(refreshToken 已失效); } redisTemplate.delete(jwt:refresh: jti); String newAccessToken createAccessToken(userId, loadRoles(userId)); String newRefreshToken createRefreshToken(userId); return RefreshResult.ok(newAccessToken, newRefreshToken); } finally { redisTemplate.delete(lockKey); } }拿到锁之后必须做双重校验防止上一个持有锁的线程已经完成刷新并删除了旧 key当前线程再查一次 Redis发现 refreshToken 已经不存在就直接返回失败。加锁和解锁之间所有 Redis 操作都要考虑「键可能不存在」的分支。锁长度 10 秒足够覆盖一次刷新操作如果业务慢导致锁超时释放并发线程会拿到锁后再次刷新但那时旧 refreshToken 已删除双重校验会挡住它。4.4 JwtFilter 里挂上刷新逻辑响应头回写新 token刷新逻辑写好后JwtFilter 的 onAccessDenied 要做一次增强先尝试解析 accessToken如果剩余时间低于阈值或已经过期就调用刷新方法成功后用新 token 继续走 Shiro 认证流程。String accessToken authorization.substring(7); Claims claims null; try { claims tokenService.parseAndValidate(accessToken); } catch (ExpiredJwtException e) { claims e.getClaims(); } long threshold 30 * 60 * 1000L; if (claims ! null claims.getExpiration().getTime() - System.currentTimeMillis() threshold) { String refreshToken httpRequest.getHeader(X-Refresh-Token); RefreshResult result tokenService.refresh(refreshToken); if (result.isOk()) { httpResponse.setHeader(X-Access-Token, result.getNewAccessToken()); httpResponse.setHeader(X-Refresh-Token, result.getNewRefreshToken()); accessToken result.getNewAccessToken(); } } getSubject(request, response).login(new JwtToken(accessToken));阈值设成 30 分钟是让「快过期」和「已过期」都能被覆盖到用户在 30 分钟的窗口内无论何时发起请求响应头里都会带上新的 token。前端拦截器从X-Access-Token响应头里取到新值后替换本地存储用户完全无感知。这里要提醒跨域时 nginx 或网关必须显式暴露这些自定义响应头否则前端 JS 读不到。5. 避坑清单这套方案里最常翻车的六个现场自动刷新方案整体不难难的是那些只有跑到线上才会暴露的问题。以下六个现象我逐个按「现象 → 原因 → 解决」来复盘。5.1 现象登录接口被 JWT 过滤器拦截返回 401明明配置了/login为 anon登录请求还是被自己的过滤器拦下来。原因是 Spring Boot 2.6 的默认路径匹配策略是 PathPatternParser而 Shiro 的过滤链用的还是 AntPathMatcher两者的匹配语义不一致导致 anon 规则没有真正生效。解决方法是必须在 application.yml 里显式声明spring.mvc.pathmatch.matching-strategy: ant_path_matcher并且过滤链中的路径要用 LinkedHashMap 保证/login排在/**之前。改了配置后重启访问登录接口看到 200 才算过关。5.2 现象每来一个请求 Redis 连接数就涨一节内存被打满无状态接口在 Shiro 的默认配置下仍然会创建 Session每次请求往 Redis 写入一条 session 数据连接数和内存自然一路飙升。这个问题在开发和测试环境很难察觉因为数据量小压力测试时才暴露。解决方式是在 SecurityManager 上把 Session 存储关闭也就是 3.3 节里的那段DefaultSessionStorageEvaluator配置。加上之后 Shiro 完全不再落 SessionJWT 无状态闭环才算真正形成。5.3 现象accessToken 还没过期refreshToken 先从 Redis 里消失了refreshToken 的 JWT exp 是 7 天但 Redis 里对应的 key 在 3 天就被删了刷新请求报「refreshToken 已失效」。排查后发现是登录时写入 Redis 的 TTL 写成了另一个常量两头时间不一致。解决方法是把 accessToken、refreshToken、Redis TTL 三个值统一收敛到配置中心由同一个参数控制。写入 Redis 前打印一条日志带上 TTL 值上线后抽查几条日志就能确认一致性。5.4 现象并发请求同时刷新后来的请求被旧 refreshToken 顶下线双 token 方案里 refreshToken 一旦轮换旧值立即作废。两个浏览器标签页同时请求业务接口第一个请求刷新成功第二个请求拿着旧 refreshToken 来换新被判定为失效用户被无感知地踢下线。解决方式就是用 4.3 节里的分布式锁加双重校验。拿到锁后再次检查 Redis 里存的 userId 是否一致不一致直接返回失败而不是抛异常返回失败时让请求用旧 accessToken 继续走一遍认证流程大部分场景旧 token 的有效期还没走完用户无感。5.5 现象注销之后改了密码旧 token 还能继续访问接口黑名单只拦了 accessTokenrefreshToken 还在意味着用户改了密码后旧会话仍能续期。很多团队只做了 logout 接口忘了在 Redis 里同步清掉该用户的全部 refreshToken。解决的方案是引入jwt:user:{userId}集合登录时把 refreshToken 的 jti 加入集合注销或改密时遍历集合删除对应 key。要保证「改了密码所有端同时下线」这一层就必须做不能只依赖 accessToken 黑名单。5.6 现象启动时报 WeakKeyExceptiontoken 签发直接失败jjwt 0.11.x 对 HS256 密钥长度有硬性要求必须大于等于 256 位也就是 32 字节。密钥配短了会在生成密钥对象时直接抛异常。解决方式是用openssl rand -base64 32生成一个安全的 Base64 密钥再通过环境变量或配置中心注入不要硬编码在代码里。密钥长度不足是这条链路里最容易自查的坑启动时报这个错基本就不用查其他原因了。6. 验证方法与进阶curl 走通全流程 压测观察刷新行为6.1 curl 验证登录、访问、刷新一次看全本地启动项目后先用一条 curl 完成登录拿到 accessToken 和 refreshTokencurl -X POST http://localhost:8080/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}再带着 accessToken 访问受保护接口并加上-D -参数打印响应头curl -X GET http://localhost:8080/api/orders \ -H Authorization: Bearer accessToken \ -H X-Refresh-Token: refreshToken \ -D -如果响应头里出现了X-Access-Token说明刷新链路已经生效。为了模拟过期场景可以把 jwt.access-expire-ms 临时改小到 30 秒跑起来后等待过期再请求一次确认自动续期能兜住过期后的第一次请求。6.2 并发刷新压测观察 401 比例用ab压测同一接口观察 200 和 401 的分布。若并发刷新没有加锁压测结果里会出现一批 401加了锁和双重校验后即使高并发场景下也不该出现由刷新失败引起的 401。压测时把阈值调大到 10 分钟让几乎所有请求都落在刷新窗口内更容易暴露并发问题。6.3 进阶方向设备指纹与网关层刷新refreshToken 的安全性还能进一步加强常见靠绑定设备指纹实现签发时记录 UA 和 IP刷新时对比当前请求是否一致不一致就拒绝续期并要求重新登录。生产环境如果接入了网关自动刷新逻辑通常从 JwtFilter 下沉到网关 GlobalFilter应用层只保留 Realm 认证和黑名单校验既保证各服务无状态也让刷新行为在多服务间保持一致。我第一次接这套组合时就是栽在 Shiro 默认 Session 存储上压测时 Redis 连接数直接被打爆排查了整整一个下午。后来把无状态配置补上、加锁做了刷新收敛这套结构才真正稳定下来。希望帮到你。本文还有配套的精品资源点击获取