十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Spring Boot整合JWT+Shiro+Redis实现token无感续签

Spring Boot整合JWT+Shiro+Redis实现token无感续签 简介面向Java后端开发者提供一套Spring Boot整合JWT、Shiro与Redis的Token自动刷新实现方案适用于需要单点登录、接口权限控制与长效会话保持的Web项目。资源围绕令牌认证与无感续期展开包含Shiro自定义Realm、JWT工具类、Redis令牌存储、刷新过滤器等核心模块能直接参考快速落地到业务系统中。压缩包内共32个文件以27个Java源码为主配合3个XML配置、1个SQL脚本和1个YAML配置文件覆盖认证流程、数据初始化与参数设置整体仅38KB目录结构清晰便于定位。目前已有6589人学习下载对希望理解Shiro与JWT结合方式、解决Token过期问题的开发者颇具参考价值。通过阅读该项目能够掌握JWT生成与解析、Shiro多Realm认证、Redis键过期与自动续期的完整思路缩短自行摸索的时间。 后端做登录认证token过期这件事几乎每个Java开发都遇到过。最恨的不是用户要重新登录而是用户刚填完一个长表单、写完一段长文案提交的一瞬间系统弹出一句“登录已过期请重新登录”。数据丢了体验也崩了。后来我在Spring Boot项目里把JWT、Shiro、Redis三个东西组合到一套认证体系里实现了token自动刷新也就是大家常说的“无感续签”这套方案在多个后台管理系统里跑了很久稳定性和可维护性都经得起检验。这篇文章把完整的设计思路、关键代码、以及踩过的坑全部整理出来给正准备做权限认证升级的朋友一个可以直接抄作业的参考。先说明一下这套方案的适用人群。如果你正在做单体后台管理系统或者刚接手一个登录模块被吐槽“老是掉线”的项目又或者你想搞懂token续签到底怎么落地那这篇文章很适合你。方案核心是JWT负责无状态身份令牌Shiro负责认证授权和拦截链路Redis负责token状态存储和刷新控制。三者分工明确没有谁替代谁而是互相补位。1. 写这套认证体系前先想清楚三个问题1.1 为什么纯JWT在真实业务里不够用JWT最吸引人的地方就是无状态、自包含。服务端不用存session拿到token一验签名就能确认身份天然适合分布式和跨域场景。但真实业务里它有几个绕不开的痛点。第一签发之后不可控。JWT一旦发出去只要没到过期时间它始终有效。用户改了密码、被封禁了、角色权限降级了服务端想让它立刻失效做不到。第二过期就得重新登录。token有效期设短了用户频繁被踢下线设长了token泄露后的风险窗口又太大怎么调都不舒服。第三续签能力弱。JWT续签只能重新生成一个新的token但旧的token在失效前依然能被使用这在“强制下线”类场景下就是漏洞。所以需要Redis来兜底。Redis在这里不是存session替代品而是给无状态的JWT加上一层“状态控制”。token的合法生命周期由Redis说了算JWT的签名和过期时间反而退居其次。这样既保留JWT无状态传输的优势又获得了服务端主动撤销的能力。1.2 Shiro和JWT怎么分工很多人一听Shiro就想到session管理感觉和JWT“无状态”是冲突的。其实关键在于怎么分配职责。我的做法是Shiro负责认证授权流程JWT负责身份传递载体。Shiro的Realm从JWT中解析出用户身份再把用户权限信息加载进来Shiro的Filter负责拦截请求、触发登录校验Shiro的注解比如RequiresPermissions负责接口级权限控制。JWT本身只存放userId、username这类非敏感信息权限数据还是走系统查询这样权限变更立即生效不会被token里缓存的旧权限拖后腿。Shiro默认依赖session我们通过配置把session存储禁用掉让Subject的创建和销毁完全围绕JWT来进行。这一步做好了Shiro和JWT非但不冲突反而互补得相当舒服。1.3 Redis在自动刷新中的定位Redis在整个方案里主要有四个用途保存token签发的“白名单状态”、保存用户会话信息、控制token续签的窗口期、实现登出后的主动失效。自动刷新的核心思想是滑动过期Sliding Expiration用户只要一直在操作token有效期就不断顺延用户长时间不活跃token自然过期。这个策略比固定过期友好太多。Redis可以很方便地给token设置TTL每次请求时如果判定需要续签就重新签发token并更新Redis里的过期时间前端拿到的是一套“一直在活动的会话”。2. 版本搭配和基础依赖先避开几个老坑2.1 依赖版本怎么选这套方案的坑一半在版本兼容上。我推荐一套目前实测非常稳的组合Spring Boot 2.7.x Shiro 1.8.0 jjwt 0.11.5 RedisLettuce客户端。组件推荐版本说明Spring Boot2.7.x成熟稳定与Shiro 1.8兼容好shiro-spring-boot-starter1.8.0自动配置省事支持Spring Boot 2.xjjwt-api / jjwt-impl / jjwt-jackson0.11.5API清晰支持JDK 8Spring Data Redis随Boot版本默认Lettuce连接池不建议用jjwt 0.9.1。那个版本在高版本JDK下会报ClassNotFoundException: javax.xml.bind.DatatypeConverter需要额外引入JAXB依赖属于历史遗留问题。0.11.x改用Keys.hmacShaKeyFor()生成密钥用parserBuilder()解析API更安全也少踩很多坑。如果你用的是Spring Boot 3.x要注意Shiro 1.8官方对Jakarta EE的兼容并不好需要加额外的适配包建议还是老老实实停在Boot 2.7这是当前最省心的组合。2.2 JWT工具类核心就这三段JWT的生成、解析、校验逻辑不复杂但一定要写对。我封装了一个JwtUtil类核心代码分三块。Component public class JwtUtil { // 密钥建议从配置文件读取不要硬编码在代码里 private static final SecretKey KEY Keys.hmacShaKeyFor( your-secret-key-please-change-to-a-long-random-string-32bytes.getBytes(StandardCharsets.UTF_8)); private static final long ACCESS_TOKEN_TTL 2 * 60 * 60 * 1000L; // 2小时 public String createToken(Long userId, String username) { Date now new Date(); Date expire new Date(now.getTime() ACCESS_TOKEN_TTL); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(now) .setExpiration(expire) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } }生成和解析是对称操作。setSubject里我放的是userId这样后面Shiro的Principal直接用Long类型避免字符串和数字之间反复转换。claim里放username方便日志和审计但绝对不能放密码等敏感信息。signWith一定要指定算法并传入SecretKey对象如果直接传字符串不同版本的jjwt处理方式不一致很容易埋坑。2.3 Redis相关的配置要点Redis配置不多说核心是使用StringRedisTemplate来操作token键值避免对象序列化带来的类型混乱问题。键的设计推荐这种格式auth:token:{userId}:{token}值是token本身或JSON化的用户会话信息TTL和access_token的有效期保持一致。这样每个用户的每个token独立可控后续做踢人下线、设备管理都有数据基础。3. 实现token自动刷新的核心设计3.1 双过期时间模型这套方案里其实存在两套过期时间理解清楚这个概念后面代码才写得明白。第一套是JWT自身的exp过期时间我这里设成2小时。第二套是Redis里存储的token记录TTL同样也是2小时。但Redis的TTL不是固定的每次用户活跃时如果满足刷新条件Redis里的TTL会被重置。换句话说JWT的过期时间兜底Redis的TTL掌握实际生命周期。实际运行效果是用户登录后连续操作3小时因为每2小时内的活跃请求都会触发续签Redis里的token记录一直续命JWT本身也一直在重新签发所以用户无感知不会掉线。用户关了电脑去睡觉第二天Redis里的TTL早就清零了这时候携带旧token访问系统Redis查不到记录直接判定未授权用户重新登录即可。3.2 滑动过期每个请求如何判断要不要刷新自动刷新不是定时任务而是“懒刷新”。我在JwtFilter里拦截每个请求解析出token后执行一段刷新判断逻辑。核心算法如下private String handleTokenRefresh(String token, HttpServletResponse response) { Claims claims jwtUtil.parseToken(token); Long userId Long.valueOf(claims.getSubject()); String redisKey auth:token: userId : token; // 1. Redis里没有这条token记录说明已失效或已登出直接拒绝 if (!redisTemplate.hasKey(redisKey)) { throw new UnauthorizedException(token已失效请重新登录); } // 2. 计算剩余有效时间 long remain claims.getExpiration().getTime() - System.currentTimeMillis(); long threshold 30 * 60 * 1000L; // 30分钟 // 3. 剩余时间不足阈值签发新token并刷新Redis if (remain threshold) { String newToken jwtUtil.createToken(userId, claims.get(username, String.class)); redisTemplate.delete(redisKey); redisTemplate.opsForValue().set( auth:token: userId : newToken, newToken, ACCESS_TOKEN_TTL, TimeUnit.MILLISECONDS); response.setHeader(X-Auth-Token, newToken); return newToken; } // 4. 还有充足时间原样返回 return token; }为什么阈值设成30分钟而不是更低根据我的实测如果阈值设成5分钟那只有非常活跃的接口才会触发刷新对“长时间填写表单后一次性提交”的场景保护不足如果设成90分钟token就等于长期有效失去了定期轮换的意义。2小时有效期加30分钟阈值安全性和体验比较均衡。刷新生效后前端从响应头X-Auth-Token里读取新token替换本地旧token即可。服务端不管前端何时切换新token旧token在Redis里的记录已经删除了所以不存在“双token同时有效”的状态窗口。3.3 并发请求下的重复刷新问题同一时刻多个接口并发请求每个请求都发现剩余时间不足阈值各自生成新token并刷新Redis就会出现A请求生成token1、B请求生成token2最终Redis里只剩token2但A请求携带token1回来时反而被判定失效。这个问题我确实遇到过。解法有几种。最简单的是加JVM锁因为单体应用里所有请求都在同一个JVM中private synchronized String refreshTokenInRedis(Long userId, String oldToken, String username) { String currentToken redisTemplate.opsForValue().get(auth:token: userId : oldToken); if (currentToken null) { throw new UnauthorizedException(token已失效); } String newToken jwtUtil.createToken(userId, username); redisTemplate.delete(auth:token: userId : oldToken); redisTemplate.opsForValue().set( auth:token: userId : newToken, newToken, ACCESS_TOKEN_TTL, TimeUnit.MILLISECONDS); return newToken; }synchronized锁的是JVM内部对单体系统完全够用。如果以后拆微服务可以把锁升级成Redis分布式锁用setIfAbsent加一个锁键拿到锁的节点负责刷新其他节点等待后重查Redis。另外我建议在刷新动作前再检查一次Redis里的旧token是否还存在如果已经被其他线程删掉了说明另一个请求已经刷新成功当前请求直接返回旧的token放行即可不要重复生成。3.4 登出与token主动撤销这套方案里登出操作变得极其简单一个delete命令就完成PostMapping(/logout) public Result logout(RequestHeader(Authorization) String authHeader) { String token resolveToken(authHeader); Claims claims jwtUtil.parseToken(token); redisTemplate.delete(auth:token: claims.getSubject() : token); return Result.success(); }Redis记录删掉后旧token即使还在JWT的有效期内再来访问时也会因为查不到记录而被拦截。这一点正是纯JWT方案做不到的“后悔药”。我还建议在Redis里维护一个用户维度的会话列表用Set结构存储该用户所有有效的token值这样管理员后台做“强制下线”“踢人”功能时遍历Set删除即可不需要额外设计表。4. Shiro整合细节和完整链路落地4.1 Shiro配置类重点看两处Shiro整合的关键是配置类我把核心配置梳理出来。第一处是SecurityManager里要禁用session存储第二处是ShiroFilterFactoryBean里要注册自定义的JwtFilter。Configuration public class ShiroConfig { Bean public JwtFilter jwtFilter() { return new JwtFilter(); } Bean public Realm jwtRealm() { return new JwtRealm(); } Bean public DefaultWebSecurityManager securityManager(Realm realm) { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(realm); // 禁用sessionSession的创建和存储全部关闭 DefaultSubjectDAO subjectDAO new DefaultSubjectDAO(); DefaultSessionStorageEvaluator sessionEvaluator new DefaultSessionStorageEvaluator(); sessionEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(sessionEvaluator); manager.setSubjectDAO(subjectDAO); return manager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager, JwtFilter jwtFilter) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 注册自定义JwtFilter替换默认的authc拦截器 factoryBean.getFilters().put(jwt, jwtFilter); MapString, String filterChainDefinitionMap new LinkedHashMap(); filterChainDefinitionMap.put(/login, anon); filterChainDefinitionMap.put(/logout, anon); filterChainDefinitionMap.put(/**, jwt); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } }禁用session存储这步非常关键。如果不关Shiro每次请求都会尝试创建session轻则多出一堆Redis垃圾数据重则接口响应莫名变慢甚至出现“明明登录了但权限丢失”的诡异问题。关掉之后Subject的身份信息完全靠JWT还原链路干净很多。过滤链里有一个很隐蔽的坑登录接口必须配置成anon否则shiro默认的认证过滤器会抢在JwtFilter之前拦截掉登录请求前端永远登不进去。/logout也配成anon避免“登出接口还要带token才能调用”的尬局面。其他接口统一走jwt过滤器。4.2 JwtRealm、JwtToken、JwtFilter三个类配合Shiro的原生逻辑是Filter把请求包装成AuthenticationToken交给SecurityManagerSecurityManager再交给Realm做认证。我们要做的就是让这个过程适配JWT。JwtToken很简单就是一个AuthenticationToken的实现类持有JWT字符串。public class JwtToken implements AuthenticationToken { private final String jwt; public JwtToken(String jwt) { this.jwt jwt; } Override public Object getPrincipal() { return jwt; } Override public Object getCredentials() { return jwt; } }JwtRealm负责两个核心方法。doGetAuthenticationInfo负责校验token、查用户doGetAuthorizationInfo负责加载权限数据。public class JwtRealm extends AuthorizingRealm { Autowired private UserService userService; Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String jwt (String) token.getCredentials(); Claims claims; try { claims jwtUtil.parseToken(jwt); } catch (Exception e) { throw new AuthenticationException(token无效或已过期); } Long userId Long.valueOf(claims.getSubject()); // 这里查一次用户确保用户未被禁用、删除 User user userService.getById(userId); if (user null || user.getStatus() 0) { throw new AuthenticationException(用户不存在或已被禁用); } return new SimpleAuthenticationInfo(userId, jwt, getName()); } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Long userId (Long) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); // 根据userId查角色、权限集合 info.addRoles(userService.listRoles(userId)); info.addStringPermissions(userService.listPermissions(userId)); return info; } }注意Realm里我用Autowired注入Service有人会遇到为null的情况通常是因为Realm被new出来而不是被Spring管理。解决方法是把Realm也注册成Bean上面配置类里已经写了这样Spring容器才能完成注入。JwtFilter继承BasicHttpAuthenticationFilter重写两个方法public class JwtFilter extends BasicHttpAuthenticationFilter { Autowired private JwtUtil jwtUtil; Autowired private StringRedisTemplate redisTemplate; Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { HttpServletRequest httpRequest WebUtils.toHttp(request); // 放行OPTIONS预检请求 if (HttpMethod.OPTIONS.name().equalsIgnoreCase(httpRequest.getMethod())) { return true; } return executeLogin(httpRequest, WebUtils.toHttp(response)); } Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest WebUtils.toHttp(request); HttpServletResponse httpResponse WebUtils.toHttp(response); String token resolveToken(httpRequest.getHeader(Authorization)); if (token null) { throw new AuthenticationException(请求头中未找到token); } // 先走刷新逻辑刷新后token可能被替换 String newToken handleTokenRefresh(token, httpResponse); // 用最新的token执行Shiro登录把用户信息塞进Subject getSubject(request, response).login(new JwtToken(newToken)); return true; } private String resolveToken(String authHeader) { if (authHeader ! null authHeader.startsWith(Bearer )) { return authHeader.substring(7); } return authHeader; } }这里有个细节我先把刷新逻辑放在Shiro登录之前这样Redis状态校验和token轮换在认证前就完成了后续Realm里拿到的一定是当前有效的token。如果先执行Shiro登录再刷新会出现Redis里的token已经被更新、但Subject里持有的还是旧token的不一致情况。BasicHttpAuthenticationFilter默认在登录失败时会重定向到loginUrl这对前后端分离项目非常不友好。所以异常抛出后我建议在onAccessDenied里统一处理成JSON响应返回。多个请求并发时synchronized方法会短暂排队但锁粒度很小实测对接口性能影响可以忽略。4.3 登录接口如何发token登录成功后同时把token写进Redis构成完整的闭环。这里我贴一下登录接口的关键写法PostMapping(/login) public Result login(RequestBody LoginDTO dto) { User user userService.login(dto.getUsername(), dto.getPassword()); if (user null) { return Result.error(用户名或密码错误); } String token jwtUtil.createToken(user.getId(), user.getUsername()); redisTemplate.opsForValue().set( auth:token: user.getId() : token, token, ACCESS_TOKEN_TTL, TimeUnit.MILLISECONDS); return Result.success().put(token, token); }登录接口本身不做刷新判断因为新token刚签发是全新的有效期完整。刷新逻辑只发生在后续的业务请求里。这里的Redis写入和JwtFilter里的刷新逻辑共享同一套TTL常量避免两边设置不一致。4.4 请求链路的完整时序把这套方案串起来一次带token的请求走完是这样前端请求携带Authorization: Bearer xxxx。JwtFilter拦截请求先放行OPTIONS预检。从Header解析token调用刷新逻辑检查Redis里token是否存在、剩余有效期是否小于阈值。如果剩余时间不足签发新token删除Redis旧记录写入新记录并在响应头X-Auth-Token带上新token。用最终可用的token调用Subject.login()进入Shiro认证Realm解析身份和权限。请求进入ControllerShiro注解执行接口级权限校验。前端读取响应头里的新token替换本地存储下次请求自动使用新token。这套链路把“自动刷新”和“权限校验”合二为一业务代码完全不用关心token续签这件事。5. 常见问题排查与避坑记录这套方案我已经看了很多人在网上提问整理成表格方便大家直接查。现象根本原因解决思路解析JWT报SignatureException签名密钥不一致或构建Key对象时编码不统一所有地方都用同一个SecretKey对象不要在配置里手输中文密钥不指定UTF-8Shiro的JwtFilter不生效接口直接403Filter没注册到ShiroFilterFactoryBean或者bean名字自定义不规范检查factoryBean.getFilters().put(jwt, jwtFilter)是否执行filter要声明为IoC管理的Bean登录接口都进不去直接报未授权登录地址没配anon被默认认证过滤器拦截把/login、/logout配置为anon其他接口再走jwt刷新后旧token依然能访问并发请求里两个线程都拿到了旧token一个刷新删key另一个已经通过校验对刷新方法加synchronized锁或者用Redis分布式锁确保同一时刻只有一个刷新动作接口响应很慢Redis里大量垃圾keyShiro默认创建session并存储按上文配置禁用session存储Authorization入参被日志打印出来不安全感爆棚日志框架打印了请求全部Header对Header打印做脱敏只记录前10位前端刷新后收到的响应头里找不到X-Auth-Token跨域CORS没配置暴露该自定义响应头服务端CORS配置里增加exposedHeaders(X-Auth-Token)OPTIONS预检请求被拦截自定义filter没有放行OPTIONS在isAccessAllowed里直接对OPTIONS返回trueSpring Boot 3 Shiro报错Shiro 1.8基于Servlet/Jakarta EE兼容性问题方案推荐用Spring Boot 2.7或者引入shiro的jakarta适配注解RequiresPermissions不生效缺少DefaultAdvisorAutoProxyCreator或开启注解代理配置类里加一个静态的DefaultAdvisorAutoProxyCreatorsetUsePrefix(true)再分享几个从实践里总结的细节技巧。第一密钥管理必须重视。密钥至少32字节不要硬编码在Java类里放在application.yml里并用环境变量注入。生产环境建议用配置中心或KMS托管。JWT的Payload里只放非敏感标识绝不能放密码、手机号、身份证这类数据。第二异常响应的格式要统一。Shiro抛出的AuthenticationException和AuthorizationException默认会走页面重定向前后端分离项目要统一捕获并返回JSON。可以在配置里注册一个全局异常处理器把这几个异常单独映射返回401和403状态码附带code、msg字段前端好做拦截和提示。第三前端切换token的策略也有讲究。响应头X-Auth-Token只有在发生刷新时才会出现所以前端正常情况不需要处理只有读到该响应头时才把它覆盖到本地存储。而且要注意在同一个请求的响应处理里完成替换而不是等页面下一次回调节点再换。关于安全加固这里特别提醒一句JWT历史上出过algnone绕过签名校验的漏洞。在使用jjwt解析时务必严格指定签名算法并用SecretKey对象不要用那种接受任意alg的宽松解析库也不要接受不带签名的token。解析代码里要捕获ExpiredJwtException、SignatureException等异常统一抛出业务异常避免把底层异常细节直接回传给前端。6. 双token方案的一个补充说明有些网上的方案会采用access_token refresh_token这种双token机制简单说就是短时token负责请求长时refresh token负责续期access过期后前端用refresh token换新的。这套方案在开放平台场景下确实好用但在我这套“后台管理系统”的场景里双token有两个问题一是前端要多管理一个refresh_token存储暴露面变大二是每次换token都要前端主动发起请求做不到完全无感。我实现的是单token 滑动过期方案刷新动作隐藏在拦截器里用户无感知前端代码量也更小。如果你的项目以后要开放第三方API授权能力那时候再加双token机制也不迟。两种方案的取舍我个人的建议是内部系统用单token滑动过期对外平台用双token。最后说几句大实话这套方案我在项目里稳定运行一段时间之后最大的感触是把刷新和校验放进拦截器里形成闭环比在业务代码里到处塞“检查过期、重新登录”逻辑舒服太多。业务层永远拿到的都是干净且有效的登录态没有人再去纠结token哪一秒过期。如果你现在项目里还是纯JWT裸奔或者还在用传统session方式做登录认证我建议你找个周末把Redis加上把这套滑动过期逻辑移植过去。配置文件不复杂代码量也不大但用户体感会明显上一个台阶。“用户一直用就一直不掉线”这句话听起来很简单真正做到位需要把JWT、Shiro、Redis三者之间的边界划分清楚。希望这篇文章能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表