
简介这份资源面向具备一定 Java Web 基础的开发者聚焦 Spring Boot 项目中身份认证与授权体系的搭建解决 JWT 令牌过期后需重新登录、频繁查库导致性能下降等实际问题。资源以 Shiro 作为安全框架结合 JWT 完成无状态认证并借助 Redis 缓存令牌实现自动刷新覆盖自定义 Realm 对接 JWT 校验、TokenStore 过期策略、TokenFilter 拦截刷新等关键环节适合需要落地权限控制与登录态续期的后端开发场景。压缩包共 32 个文件以 27 个 Java 源码为主体辅以 3 个 XML 配置、1 个 SQL 初始化脚本和 1 个 YML 配置文件整体约 38KB结构紧凑便于按模块阅读。目前已有 6593 人学习下载读者可从中获取一套可运行的整合示例理解认证流程串联方式与令牌刷新思路作为项目实践或技术选型参考。1. 双Token加Redis黑名单这套SpringBoot权限方案到底解决什么问题登录状态管理这件事做过后台系统的都懂用户登录后拿一个JWT前端每次请求带上后端验签放行看起来干净利落。但真跑起来问题一个接一个——token过期了用户正在填表单一提交直接401体验稀碎想强制某个账号下线JWT无状态根本管不住多端登录时旧token还能用安全审计过不了。这套基于SpringBoot整合JWT、Shiro和Redis实现token自动刷新的方案核心就是解决这三个痛点用双token机制access token短命、refresh token长命做无感续期用Redis存refresh token和黑名单做可控失效用Shiro的Realm和Filter链把认证逻辑收口到一处。适合正在做后台权限模块、被token过期和强制下线折腾过的后端开发也适合想搞清楚Shiro在前后端分离场景下怎么改造的同行。下面按“机制怎么转→代码怎么落→坑在哪”的顺序拆。2. 双Token刷新机制access token与refresh token怎么分工2.1 为什么单token撑不住双token的分工逻辑单token方案的本质矛盾是有效期设短了用户频繁掉线设长了泄露风险窗口太大而且没法主动作废。双token把“通行证”和“续期凭证”拆开——access token有效期压到15到30分钟只用来访问业务接口验签通过就放行不查库不查Redis保证性能refresh token有效期给到7天只用来换新access token存Redis里服务端随时能删。这样即使access token被截获攻击窗口也就半小时而refresh token因为只在刷新接口出现暴露面小得多。这里有个容易混淆的点refresh token换新的时候要不要把旧的refresh token也换掉我的做法是换而且旧refresh token立即从Redis删除。这叫refresh token轮换能防重放——同一个refresh token被用两次第二次直接判定异常把该用户所有refresh token清空强制重新登录。代价是前端并发刷新时要加锁否则两个请求同时拿旧refresh token去换一个成功一个失败失败那个把用户踢下线这就是血泪经验里最常见的翻车点。2.2 Redis里存什么key设计、过期策略与黑名单Redis在这套方案里干三件事存refresh token、存access token黑名单、存用户登录态元信息。key的设计直接决定后面排查顺不顺手我一般这么定用途key格式value过期时间refresh tokenauth:rt:{userId}:{deviceId}refresh token字符串7天access黑名单auth:bl:{jti}1剩余有效期登录元信息auth:meta:{userId}:{deviceId}JSON登录时间、IP、UA7天refresh token按userId加deviceId存支持多端登录互不影响踢某端就删对应key。黑名单用access token里的jtiJWT ID做key过期时间设成该token的剩余寿命——token自然过期后黑名单条目自动消失不用手动清理这是省事的关键。登录元信息用来做“当前在线设备”列表和异地登录提醒不是必须但排查问题时能省很多口舌。提示黑名单的过期时间一定要用token剩余有效期不是固定值。设固定值要么清理不及时占内存要么提前失效导致已注销token还能用。2.3 刷新时序从请求拦截到无感续期的完整链路完整链路是这样的前端请求带access token → Shiro的JwtFilter验签 → 验签失败且是过期异常 → 返回特定状态码比如401且body带token_expired标记→ 前端拦截器捕获暂停当前请求拿refresh token调刷新接口 → 刷新接口校验refresh token在Redis中存在且匹配 → 生成新access token和新refresh token旧refresh token删除 → 返回新token对 → 前端更新本地存储重放刚才暂停的请求。整个过程用户无感知表单不丢。刷新接口本身要放行不能走JwtFilter否则refresh token过期时连刷新都进不去。Shiro的filterChainDefinitionMap里把/auth/refresh配成anon。另外刷新接口要做并发控制同一userIddeviceId的刷新请求用Redis的SETNX加个短锁比如3秒拿不到锁的请求直接返回“刷新中请稍后重试”前端收到这个码就等200毫秒再重试避免轮换机制下自己把自己踢掉。3. Shiro整合JWTRealm、Filter与前后端分离的改造点3.1 自定义Realm认证走JWT验签授权走缓存Shiro默认的Realm是用户名密码模式前后端分离下要改成JWT模式。核心是重写doGetAuthenticationInfo从请求头取token验签解析出userId构造SimpleAuthenticationInfo返回。注意这里不要再去查数据库比对密码——密码校验发生在登录接口token验签通过就代表身份可信。授权部分doGetAuthorizationInfo按userId从Redis或本地缓存取角色权限列表塞进SimpleAuthorizationInfo。public class JwtRealm extends AuthorizingRealm { Override public boolean supports(AuthenticationToken token) { // 只处理自定义的JwtToken不抢UsernamePasswordToken的活 return token instanceof JwtToken; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { String jwt (String) token.getPrincipal(); Claims claims; try { claims JwtUtil.parse(jwt); // 验签解析签名不对直接抛异常 } catch (ExpiredJwtException e) { throw new ExpiredCredentialsException(token已过期); } catch (JwtException e) { throw new IncorrectCredentialsException(token非法); } String userId claims.getSubject(); // 查黑名单注销或强制下线的token直接拒 if (redisTemplate.hasKey(auth:bl: claims.getId())) { throw new IncorrectCredentialsException(token已失效); } return new SimpleAuthenticationInfo(userId, jwt, getName()); } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String userId (String) principals.getPrimaryPrincipal(); SetString perms permissionCache.get(userId); // 权限走缓存别每次查库 SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.setStringPermissions(perms); return info; } }这段代码有两个参数要留意supports方法必须只认自定义Token类型否则Shiro会把登录接口的账号密码认证也路由到这个Realm登录直接挂黑名单检查放在验签之后、返回之前顺序不能反先验签能挡掉大部分伪造请求减少Redis无效查询。权限缓存建议用Caffeine本地缓存加Redis二级缓存权限变更时主动清别设太长TTL否则改了角色要等缓存过期才生效运维会来找你。3.2 JwtFilter拦截规则、放行路径与异常分流JwtFilter继承AuthenticatingFilter重写isAccessAllowed和onAccessDenied。isAccessAllowed里取请求头token为空返回false不为空调executeLogin触发Realm认证。onAccessDenied是异常分流的关键——token过期和token非法要返回不同状态码前端才能区分“该刷新”还是“该重新登录”。public class JwtFilter extends AuthenticatingFilter { Override protected boolean isAccessAllowed(ServletRequest req, ServletResponse resp, Object mappedValue) { HttpServletRequest request (HttpServletRequest) req; String token request.getHeader(Authorization); if (token null || token.isEmpty()) { return false; } try { executeLogin(req, resp); // 触发JwtRealm认证 return true; } catch (Exception e) { request.setAttribute(auth_error, e.getCause() ! null ? e.getCause() : e); return false; } } Override protected boolean onAccessDenied(ServletRequest req, ServletResponse resp) throws IOException { HttpServletResponse response (HttpServletResponse) resp; response.setContentType(application/json;charsetUTF-8); Throwable error (Throwable) req.getAttribute(auth_error); String body; if (error instanceof ExpiredCredentialsException) { response.setStatus(401); body {\code\:40101,\msg\:\token_expired\}; // 前端据此触发刷新 } else { response.setStatus(401); body {\code\:40102,\msg\:\token_invalid\}; // 前端直接跳登录 } response.getWriter().write(body); return false; } }状态码约定要跟前端对齐死40101走刷新流程40102清本地存储跳登录页。别用HTTP 403混着用前端判断起来容易出错。另外executeLogin抛的异常会被Shiro包一层取getCause()才能拿到Realm里抛的具体类型这个细节不处理异常分流永远走else分支刷新逻辑根本触发不了。3.3 Shiro配置Filter链顺序与匿名路径清单Shiro的ShiroFilterFactoryBean里filterChainDefinitionMap用LinkedHashMap保证顺序/**放最后。登录、刷新、验证码、静态资源这些必须anon顺序错了会出现“刷新接口被JWT拦截”的死循环。Bean public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean factory new ShiroFilterFactoryBean(); factory.setSecurityManager(securityManager); MapString, Filter filters new HashMap(); filters.put(jwt, new JwtFilter()); factory.setFilters(filters); MapString, String chain new LinkedHashMap(); chain.put(/auth/login, anon); chain.put(/auth/refresh, anon); // 刷新接口必须放行 chain.put(/auth/captcha, anon); chain.put(/static/**, anon); chain.put(/**, jwt); // 兜底放最后 factory.setFilterChainDefinitionMap(chain); return factory; }常见做法是把anon路径抽到配置文件里改路径不用动代码。注意/**的jwt必须最后putLinkedHashMap按插入顺序匹配放前面所有请求都被拦登录接口也进不去这个坑新手几乎必踩一次。4. 自动刷新的落地细节并发、注销与多端控制4.1 刷新接口的并发安全分布式锁与轮换冲突前面提过refresh token轮换的并发问题这里给具体实现。刷新接口进来先拿锁key用auth:lock:{userId}:{deviceId}SETNX加3秒过期拿不到锁返回“刷新中”。public TokenPair refresh(String refreshToken) { Claims claims JwtUtil.parse(refreshToken); String userId claims.getSubject(); String deviceId claims.get(device, String.class); String lockKey auth:lock: userId : deviceId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { throw new BizException(REFRESHING, 刷新中请稍后重试); } try { String stored redisTemplate.opsForValue() .get(auth:rt: userId : deviceId); if (stored null || !stored.equals(refreshToken)) { // 不匹配说明被轮换过或已注销清空该端登录态 redisTemplate.delete(auth:rt: userId : deviceId); throw new BizException(RELOGIN, 登录已失效请重新登录); } String newAccess JwtUtil.createAccess(userId, deviceId); String newRefresh JwtUtil.createRefresh(userId, deviceId); redisTemplate.opsForValue().set(auth:rt: userId : deviceId, newRefresh, 7, TimeUnit.DAYS); // 覆盖旧refresh完成轮换 return new TokenPair(newAccess, newRefresh); } finally { redisTemplate.delete(lockKey); } }锁的过期时间给3秒足够刷新逻辑就是几次Redis操作加签名不会超。finally里删锁要判断是不是自己加的锁——严格做法是value存个随机串删之前比对防止误删别人的锁。这里为了简洁没展开生产环境建议补上。轮换冲突的处理原则是refresh token不匹配就清空该端登录态宁可让用户重登也不能让旧token有复活机会。4.2 注销与强制下线黑名单写入与refresh清理注销接口做两件事把当前access token的jti写黑名单过期时间取token剩余寿命删掉对应deviceId的refresh token。强制下线则是按userId查出所有deviceId批量删refresh token同时把该用户所有未过期的access token jti写黑名单——后者需要额外维护一个“用户活跃jti集合”登录时写入、注销时移除不然没法枚举。嫌麻烦的话可以只删refresh tokenaccess token等它自然过期最多半小时多数业务能接受。public void logout(String accessToken, String userId, String deviceId) { Claims claims JwtUtil.parse(accessToken); long remain claims.getExpiration().getTime() - System.currentTimeMillis(); if (remain 0) { redisTemplate.opsForValue().set(auth:bl: claims.getId(), 1, remain, TimeUnit.MILLISECONDS); // 剩余寿命做TTL } redisTemplate.delete(auth:rt: userId : deviceId); redisTemplate.delete(auth:meta: userId : deviceId); }黑名单TTL用剩余毫秒数别用固定值。多端控制靠deviceId隔离前端登录时生成一个设备标识可以用UA哈希加随机串存本地每次登录和刷新都带上服务端按这个维度管理踢哪端删哪个key互不干扰。5. 避坑排查五个让刷新逻辑翻车的真实场景5.1 刷新接口被JWT拦截前端死循环现象token过期后前端调刷新接口返回401前端又触发刷新无限循环。原因/auth/refresh没配anon或者配了但/**的jwt在LinkedHashMap里排前面先匹配上了。解决确认刷新路径在chain里是anon且位置在/**之前用LinkedHashMap不用HashMap。排查时打个日志看请求进的是哪个filter一目了然。5.2 并发刷新把用户踢下线现象页面同时发多个请求token过期后多个请求同时触发刷新一个成功其余报“登录已失效”。原因refresh token轮换下第二个请求拿旧token去换Redis里已被覆盖判定不匹配清空登录态。解决前端刷新加单例锁Promise缓存同一时刻只发一个刷新请求其余等结果后端加SETNX短锁兜底拿不到锁返回“刷新中”前端延迟重试而不是直接跳登录。5.3 黑名单不生效注销后token还能用现象调了注销接口拿旧access token请求业务接口仍然200。原因JwtRealm里没查黑名单或者黑名单key拼错jti取成了subject。解决确认Realm的doGetAuthenticationInfo里有黑名单检查key用claims.getId()不是getSubject()用Redis客户端手动GET auth:bl:{jti}验证写入是否成功TTL是否为剩余寿命。5.4 时钟偏差导致token刚签发就过期现象服务端集群部署某台机器签发的token到另一台验签报过期。原因服务器之间时间不同步JWT的exp校验有容差但默认很小。解决JWT解析时设置允许的时钟偏差jjwt的setAllowedClockSkewSeconds(60)同时运维层面保证NTP同步。这个坑隐蔽日志里只看到ExpiredJwtException容易误判成刷新逻辑问题。5.5 Redis重启后全员掉线现象Redis重启或主从切换refresh token全丢所有用户刷新失败被踢。原因refresh token只存Redis没持久化或者用了没开AOF的实例。解决refresh token所在Redis开AOF持久化业务上接受“Redis故障需重登”的话至少把黑名单和refresh分开实例别让缓存雪崩连带认证全挂。更稳的做法是refresh token同时落库一份Redis只做加速这个看业务对可用性的要求取舍。6. 验证与调优用一组脚本把刷新链路跑穿代码写完不等于能用我习惯用一组curl脚本把整条链路跑一遍比点页面靠谱。先登录拿token对再等access过期或者临时把有效期改成10秒然后模拟并发刷新看锁是否生效最后注销验证黑名单。# 1. 登录拿access和refresh curl -s -X POST http://localhost:8080/auth/login \ -H Content-Type: application/json \ -d {username:demo,password:123456,deviceId:dev-01} # 2. 用access访问业务接口应200 curl -s http://localhost:8080/api/user/info -H Authorization: $ACCESS # 3. 并发刷新同时发两个预期一个成功一个返回REFRESHING curl -s -X POST http://localhost:8080/auth/refresh \ -H Content-Type: application/json -d {\refreshToken\:\$REFRESH\} curl -s -X POST http://localhost:8080/auth/refresh \ -H Content-Type: application/json -d {\refreshToken\:\$REFRESH\} wait # 4. 注销后旧access应401且body为token_invalid curl -s -X POST http://localhost:8080/auth/logout -H Authorization: $ACCESS curl -s http://localhost:8080/api/user/info -H Authorization: $ACCESS跑这套脚本重点看三个返回值并发刷新时是否只有一个拿到新token对、另一个返回“刷新中”注销后旧access是否立刻40102刷新后旧refresh是否失效拿旧refresh再刷一次应报RELOGIN。三个都对链路基本通了。调优上有两个参数值得盯access token有效期和黑名单内存占用。有效期越短越安全但刷新越频繁15分钟是多数后台的平衡点黑名单条目数约等于“活跃用户数×单位时间登录次数”量级不大但如果发现Redis内存异常增长先查黑名单TTL是不是写成了固定值。另外权限缓存TTL别超过5分钟角色变更的生效延迟用户能感知到。从那以后我每次接这类认证模块都强制先把并发刷新和注销黑名单这两个场景用脚本跑穿再联调前端——这两个地方翻车前端同学排查半天最后发现是后端token状态不对来回扯皮最耗时间。希望帮到你。本文还有配套的精品资源点击获取