简介:这份资源面向具备一定 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 token | auth:rt:{userId}:{deviceId} | refresh token字符串 | 7天 |
| access黑名单 | auth:bl:{jti} | "1" | 剩余有效期 |
| 登录元信息 | auth:meta:{userId}:{deviceId} | JSON(登录时间、IP、UA) | 7天 |
refresh token按userId加deviceId存,支持多端登录互不影响,踢某端就删对应key。黑名单用access token里的jti(JWT 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。另外刷新接口要做并发控制:同一userId+deviceId的刷新请求,用Redis的SETNX加个短锁(比如3秒),拿不到锁的请求直接返回“刷新中,请稍后重试”,前端收到这个码就等200毫秒再重试,避免轮换机制下自己把自己踢掉。
3. Shiro整合JWT:Realm、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(); Set<String> 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;charset=UTF-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); Map<String, Filter> filters = new HashMap<>(); filters.put("jwt", new JwtFilter()); factory.setFilters(filters); Map<String, 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必须最后put,LinkedHashMap按插入顺序匹配,放前面所有请求都被拦,登录接口也进不去,这个坑新手几乎必踩一次。
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 token,access 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状态不对,来回扯皮最耗时间。希望帮到你。
本文还有配套的精品资源,点击获取