资讯动态

Shiro整合JWT:前后端分离下的无状态认证与权限实战

发布时间:2026/10/13 5:07:12 来源:尧图企业网站定制
如果你也是前后端分离项目正在纠结要不要把 Shiro 和 JWT 硬凑在一起我可以先给结论能做而且整体改下来体验比硬生生保留 session 要舒服得多。前阵子我接手一个老系统登录认证一直是 Shiro 的 session 模式单机跑没什么问题后来要同时支撑 Web、小程序和 Appsession 的麻烦事一件接一件冒出来。最后我选了 Shiro 整合 JWT把认证改成无状态同时继续保留 Shiro 的 Realm、Subject 和注解授权体系。这篇东西不打算重复讲概念而是把我这次改造的完整思路、能直接抄的代码骨架以及几个差点让我怀疑人生的坑都记录下来给后面准备动这套技术组合的朋友做个参考。1. 老授权体系在接口场景下的三个“不够用”1.1 session 共享与分布式部署死磕先说最痛的点。传统 Web 项目里Shiro 默认通过 sessionId 维护登录态浏览器每次请求带上 Cookie服务端拿着 sessionId 去 SessionManager 里查会话。单机部署没问题但接口服务一旦部署多个实例用户登录落在 A 节点下一次请求被负载均衡转发到 B 节点B 节点没有这份 session就把人当成未登录。当时我第一反应是上共享 Session把 Shiro 的 SessionDAO 指向 Redis。听起来简单实际操作却是一堆细节sessionId 的序列化格式要对得上、Shiro 的 Session 过期要能和 Redis 的 key 过期对齐、多个节点同时读写还要考虑并发覆盖。更难受的是运维侧做弹性伸缩时新节点起来之后如果 Redis 里读不到会话用户又得重新登录。问题不是说共享 Session 不成熟而是“为了维持一个会话状态被迫引入一套会话中间件”这部分复杂度本可以省掉。1.2 一次请求要证明两件事你是谁、你能干嘛Shiro 把安全切成两件事Authentication认证和 Authorization授权。认证解决“你是谁”授权解决“你能干什么”。传统 session 模式下认证通过后把用户身份放到会话里下一次请求凭 Cookie 找回身份授权则是在需要的时候从用户、角色、权限表里加载权利。JWT 的引入把“身份证明”做成了一段自包含且被签名保护的字符串服务端拿到之后验签从里面读取用户标识不再依赖服务端 session。但这里要注意JWT 解决的是认证那一段授权仍然需要一套机制去判断角色和权限。如果你只拿 JWT 当普通 token在过滤器里解析一下就直接“放行”那等于把 Shiro 辛辛苦苦搭建的授权模型给扔了后面所有 RequiresPermissions 都没法用。1.3 摆脱 session 不等于丢掉 Shiro所以正确思路不是拿 JWT 替换 Shiro而是让 JWT 变成 Shiro 能识别的一种认证凭证。具体做法我后面会展开自定义一个 AuthenticationToken让 JWT 字符串充当 principal 和 credentials自定义 Realm在 Shiro 的认证流程里解析 JWT、加载用户、构建 SimpleAuthenticationInfo。这样 Subject.login 的入口还在Realm 的授权出口还在Shiro 的注解权限拦截也还在。整条链路变成了“JWT 负责来者不拒的验签Shiro 负责进门之后的管理”。2. 动手之前先把令牌设计和权限加载方式定死2.1 token 的 claims 里只放大身份不放权限JWT 是给前端拿着的一旦签发服务端不能主动改内容。所以设计 claims 时要非常克制。我实际用的字段大概是这样claim内容说明sub用户ID身份主键Realm 里靠它加载用户loginName登录名方便审计日志显示jti随机 UUID用于黑名单、踢人、多端控制exp过期时间access token 一般 1~2 小时我强烈建议不要把角色编码和权限编码写进 JWT。权限是经常变的就算你写了“管理员”三个字角色改绑权限之后老 token 只要没过期权限判断就对不上。你要是为了“能读出来”就把权限塞进去后期一定会在权限刷新问题上卡住。密码、手机号这类敏感信息更别放token 泄露一次影响面就大了。签名算法我用的 HS256密钥放在配置中心或者环境变量里至少 32 个字节。以前我也图省事把密钥写死在代码里后来被安全团队要求整改。密钥这东西单独拎出来管理别跟业务代码混在一起。2.2 登录、携带、验证的三段式流程理顺流程比写代码更重要。我的完整链路是这样的第一个环节登录。用户拿用户名密码来服务端校验通过后生成一个 access token 返回给前端。这个 token 的有效期短我是按 2 小时设计的避免泄露后长期有效。第二个环节携带。前端每次请求在 Authorization 头带上Bearer xxxxx小程序、App、网页都能统一处理不用再依赖浏览器自动带 Cookie。第三个环节验证。Shiro 过滤器从 Header 里取出 JWT先做签名校验再从中取出用户ID构造一个 JwtToken交给SecurityUtils.getSubject().login(jwtToken)。Subject.login 会走进我们自定义的 RealmRealm 再根据用户ID去加载用户、角色、权限最终把登录用户放进当前请求上下文。这里有一个很多人误解的地方无状态不是“服务端不校验”而是“服务端不保存登录状态”。每次请求仍然要做一次验签和用户加载只是不用把 session 存在内存或 Redis 里。2.3 刷新令牌与 Redis 状态控制JWT 一旦签发在过期前无法从服务端主动作废这是它最大的短板。为了不让这个短板变成事故我选了 access token refresh token 的组合。access token 有效期短2 小时左右refresh token 有效期长7 天并且我把 refresh token 存在 Redis 里key 是refresh:userId:randomvalue 是当前设备的 jti。access token 过期后前端拿 refresh token 调用刷新接口服务端校验 refresh token 是否存在于 Redis、是否还有效通过后再签发一个新的 access token。这套设计的价值在于退出登录时可以直接删除 Redis 里的 refresh token让这个设备再也刷不出新 token如果再配合黑名单机制还能让当前未过期的 access token 提前失效。JWT 本身的“不可作废”特性被 Redis 在外面兜了一层。2.4 授权数据从哪来实时查库还是缓存doGetAuthorizationInfo 里的权限加载是整合时最容易忽略的性能点。如果你在 Realm 里每次请求都select * from permission join role ...用户多起来之后DB 压力肉眼可见地涨。我的做法是Realm 只做用户权限的“数据来源申请”通过用户服务层去加载服务层自己带缓存。缓存可以用 Redis也可以用 Shiro 自带的授权缓存。Shiro 的 AuthorizingRealm 支持授权缓存开启之后同一用户的角色、权限在缓存过期前不会重复加载。但我个人更推荐把缓存放在用户服务层因为服务层知道权限改动的时机可以主动删缓存而不是等 Shiro 的缓存过期。如果你开了 Shiro 授权缓存记得权限变更后要清对应用户的授权缓存否则用户被去掉某个按钮权限还得等缓存过期才生效。这个问题我在第 5 章专门展开。3. 整合链路的最小可运行实现3.1 依赖清单先看 Maven 依赖。我用的技术栈是 Spring Boot 2 Shiro 1.8.0 JJWT关键依赖如下dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring-boot-starter/artifactId version1.8.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 /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencyShiro 用 1.8.0 是因为它和 Spring Boot 2.x 配合比较稳注意不要随便升到 2.0 以上的 Shiro 版本API 变化比较大网上大量资料都基于 1.x。3.2 自定义 JwtToken 与 Realm要让 Shiro 认 JWT第一步是写一个 JwtToken让 Shiro 的认证入口认识这种凭证。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; } }接着是自定义 Realm。supports 方法告诉 Shiro“JwtToken 这种凭证归我管”。doGetAuthenticationInfo 负责解析 JWT、加载用户doGetAuthorizationInfo 负责加载角色权限。public class JwtRealm extends AuthorizingRealm { Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String jwt ((JwtToken) token).getPrincipal().toString(); Long userId JwtUtil.parseUserId(jwt); User user userService.getById(userId); if (user null || user.getStatus() ! 1) { throw new UnknownAccountException(用户不存在或已禁用); } LoginUser loginUser new LoginUser(user.getId(), user.getLoginName()); return new SimpleAuthenticationInfo(loginUser, jwt, getName()); } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { LoginUser loginUser (LoginUser) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.setRoles(userService.findRoleCodes(loginUser.getUserId())); info.setStringPermissions(userService.findPermissionCodes(loginUser.getUserId())); return info; } }这里有个细节SimpleAuthenticationInfo 的 principal 一定要传一个稳定的 LoginUser 对象不是 JSON 字符串也不是 Map。后面你在 Controller 里拿SecurityUtils.getSubject().getPrincipal()时强转成 LoginUser 就能直接用。3.3 过滤器把请求头 token 变成一次登录JWT 过滤器是整条链的核心。我选择继承 Shiro 的 AccessControlFilter而不是 OncePerRequestFilter因为我要让它嵌进 Shiro 的 FilterChain 里配合 filterChainDefinitionMap 使用。public class JwtFilter extends AccessControlFilter { Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { String token getToken((HttpServletRequest) request); if (token null) { return false; } try { SecurityUtils.getSubject().login(new JwtToken(token)); return true; } catch (Exception e) { return false; } } Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setStatus(401); httpResponse.setContentType(application/json;charsetUTF-8); httpResponse.getWriter().write({\code\:401,\msg\:\未登录或token已失效\}); return false; } private String getToken(HttpServletRequest request) { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { return header.substring(7); } return null; } }过滤器的逻辑很直白没 token直接拒绝有 token就交给 Subject.login 去走 Realm 认证。认证成功请求放行认证失败返回 401 JSON。不要在这里抛异常否则会被框架捕获再走一层重定向或页面跳转前后端分离项目最怕这个。3.4 Shiro 配置类realm、session、过滤器链一次配齐配置类里最容易踩坑的地方是 JwtFilter 的注册方式。我直接给结论千万不要把 JwtFilter 定义成Bean也不要让它在类上标Component否则 Spring Boot 会把它当成普通 Servlet Filter 注册到 Servlet 容器跑到 Shiro 过滤链前面所有请求还没进 Shiro 就被拦了。我的配置核心如下Configuration public class ShiroConfig { Bean public JwtRealm jwtRealm() { return new JwtRealm(); } Bean public DefaultWebSecurityManager securityManager() { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(jwtRealm()); DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); sessionManager.setSessionIdCookieEnabled(false); sessionManager.setSessionValidationSchedulerEnabled(false); manager.setSessionManager(sessionManager); return manager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); JwtFilter jwtFilter new JwtFilter(); factoryBean.setFilters(Collections.singletonMap(jwt, jwtFilter)); factoryBean.setSecurityManager(securityManager); MapString, String chain new LinkedHashMap(); chain.put(/login, anon); chain.put(/captcha, anon); chain.put(/error, anon); chain.put(/**, jwt); factoryBean.setFilterChainDefinitionMap(chain); return factoryBean; } Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor( SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } }filterChainDefinitionMap 里登录、验证码、错误页面放行其余全部走 JwtFilter。这样未登录用户访问业务接口会被 JwtFilter 拦截并返回 JSON而不会出现 Shiro 默认的“重定向到登录页”。3.5 登录接口与受保护接口的完整闭环登录接口不用走 Shiro 的 JWT 过滤链它直接校验用户名密码签发 token 返回。简化实现大概是RestController public class LoginController { PostMapping(/login) public R login(RequestBody LoginParam param) { LoginUser loginUser loginService.login(param.getLoginName(), param.getPassword()); if (loginUser null) { return R.error(账号密码错误); } String token JwtUtil.createToken(loginUser); return R.ok().data(token, token); } }受保护接口继续用 Shiro 注解RestController RequestMapping(/user) public class UserController { GetMapping(/list) RequiresPermissions(user:list) public R list() { LoginUser loginUser (LoginUser) SecurityUtils.getSubject().getPrincipal(); return R.ok().data(currentUser, loginUser); } }前端带Authorization: Bearer xxx访问/user/listJwtFilter 认证通过RequiresPermissions 再去校验权限。整个闭环就这样打通了。4. 我踩过的坑每个都有完整的排查链路4.1 自定义过滤器被 Spring 提前注册匿名接口全挂症状非常诡异代码写得和网上示例一模一样但服务启动后连/login都返回“未登录或token已失效”。整个应用像是被一堵看不见的墙拦在门外。排查时我首先看日志发现 JwtFilter 的执行时机比 Shiro 的 FilterChain 还要早。打断点才知道因为我在配置类里写了Bean JwtFilterSpring Boot 会把所有 Filter 类型的 Bean 自动注册到 Servlet 容器。Servlet 容器里的过滤器在 Shiro 过滤链之前执行JwtFilter 对没有 token 的请求一律返回 401所以/login还没走到 Shiro 就被拦了。解决方案是把 JwtFilter 的Bean去掉改成在 shiroFilterFactoryBean 里手动 new。如果你需要给 JwtFilter 注入 Service那就在配置类里Autowired对应的 Service然后new JwtFilter(userService)让这个对象只存在于 Shiro 过滤链内部不暴露给 Spring Boot 的 Servlet 自动注册机制。4.2 Shiro 提示找不到对应的 Realm主题的坑出在subject.login(new JwtToken(token))执行时控制台抛No AuthenticationToken found for [class com.xxx.JwtToken]。这行报错看起来像是因为没有 Realm 能处理这个 token。排查的方法是跟一下 Shiro 源码。SecurityManager 在认证时会遍历所有已配置的 Realm逐个调用realm.supports(authenticationToken)。如果有任何一个 Realm 返回 true就用它执行认证如果全部返回 false就直接抛上面的异常。我的情况就是自定义 Realm 里的 supports 方法没写对一开始返回了true后来改成 JwtToken 判断时漏了instanceof导致 class 类型匹配不上。还有一个可能SecurityManager 里没有 setRealm或者 setRealm 的不是同一个 Spring Bean。你把manager.setRealm(jwtRealm())和 supports 方法都检查一遍基本能解决。4.3 认证过了但 Principal 变成 JSON 字符串有一次改造前端反馈“接口能调通但日志里用户名显示不出来”。我查了接口代码明明从 principal 里拿的是 LoginUser结果打出来的却是一串 JSON 字符串。后来排查到源头在 Realm 里。之前图省事我直接在 SimpleAuthenticationInfo 的 principal 参数里传了JSON.toJSONString(user)Shiro 自然就把这串 JSON 当成 Principal 存起来了。Controller 强转 LoginUser 时其实拿到的是 String编译期不会报错运行期一调属性就出问题。从那次之后我把 Principal 规范成了一个轻量的 LoginUser 对象只放 userId、loginName 这种常用字段并且实现 Serializable。传对象比传字符串靠谱强转也安全权限方法里也能直接拿 getUserId。4.4 token 过期后不是跳登录页而是返 JSON前后端分离项目里最怕 Shiro 默认的“登录失效后重定向到 loginUrl 登录页”。我用 JwtFilter 拦截后一开始以为重定向不会再发生结果前端在浏览器里调试点击某个业务按钮后页面直接变成了登录页 HTML状态码还是 302。排查后发现问题出在 onAccessDenied 里。虽然 JwtFilter 中写了 401 JSON但过期 token 在 JwtUtil.parseUserId 那一步抛了 ExpiredJwtExceptionisAccessAllowed 返回 falseonAccessDenied 如果写的是跳转逻辑就会把响应变成重定向。解决方法是统一在 onAccessDenied 里写 JSON 响应状态码设 401。前端在拦截器里看到 401不跳浏览器登录页而是调用 refresh 接口用 refresh token 换新 token。整个过程用户无感知。4.5 注解权限不生效接口放开得像裸奔RequiresPermissions 是 Shiro 授权的好东西但我在整合后第一次测试时发现只要 JWT 认证通过的人就算没有user:list权限也能正常访问/user/list。这说明授权注解根本没被拦截。排查分两步。第一看配置类里有没有AuthorizationAttributeSourceAdvisor并且它的 SecurityManager 有没有被设置。shiro-spring-boot-starter 在你不覆盖 SecurityManager 时会自动配置但一旦你自定义了 SecurityManager自动配置可能失效需要显式声明 advisor。第二检查 Controller 的方法修饰符是否 private/finalSpring AOP 不能代理这些类型的方法。我最终的配置就是 3.4 节那段代码加上 advisor 之后注解立即生效。测试时用两个不同权限的账号交替访问一个 200一个 403肉眼可见地正常了。4.6 无状态模式下 session 还在偷偷创建这是最隐蔽的一个坑。我用 curl 在本地连续调用接口接口本身没传 Cookie但响应头里居然出现了Set-Cookie: JSESSIONID...。这意味着虽然前端没有主动用 session服务端却在每个请求里都创建了一个 session。用户量一大服务端内存或 Redis 里就会堆满无用的 session。排查后确认是 Shiro 在Subject.login过程中给 Subject 绑定了 session即使我们不让前端回传 Cookiesession 也已经创建了。解决思路是“主动关闭 session 存储”在 sessionManager 里关闭 sessionIdCookieEnabled、关闭 Session 校验同时通过 DefaultSubjectDAO 把 session 存储关闭。如果你不想在这些底层配置上花太多时间最低限度是确保 Controller 里永远不调用SecurityUtils.getSubject().getSession()只调用getPrincipal()。但我仍然建议把 sessionManager 的 Cookie 关掉否则你会看到一堆无意义的 Set-Cookie。5. 整合完成后的权限刷新、吊销与多端登录扩展5.1 用户权限改了要等多久才生效JWT 里不放权限权限从 Realm 加载这本是好事。但如果开了 Shiro 授权缓存权限变更后缓存不失效用户就还是看到旧权限。处理方式分两层如果用的是 Shiro 授权缓存权限变更时删除 Shiro 的 authorizationCache如果像我一样把权限放到用户服务层缓存就删用户对应的权限缓存 key。实际项目里管理员给用户重新分配角色后应该主动调用一次“删除该用户权限缓存”的操作而不是等着缓存自然过期。我见过一个上线事故运营改了用户角色用户那边半小时内权限没变化反馈“系统没生效”最后就是缓存没及时清。5.2 黑名单机制让 token 在过期前也能“作废”JWT 的优势是无状态但这意味着服务端不能主动让一个还在有效期内的 token 失效。为了弥补这个短板我引入了 jti Redis 黑名单。登录时在 JWT 的 jti 字段放一个随机 UUID。过滤器解析 token 后先从 Redis 查blacklist:{jti}如果存在就直接 401。用户退出登录时把当前 token 的 jti 写入黑名单过期时间设置成 token 剩余的存活时间。这样即使 token 还有 1 小时过期一退出就立刻不能用了除非黑名单 Redis 突然故障。性能和安全性要平衡。每次请求多一次 Redis 查询对绝大多数接口项目来说可以接受。如果 QPS 特别高可以把黑名单在本地缓存同步一份但要注意缓存一致性的复杂度。5.3 多端登录、单端踢出的控制思路有了 jti多端控制也可以做得干净。我实际用到的方案是每个客户端登录时带一个 deviceId服务端在 Redis 里维护device:{userId}:{deviceId}value 是当前有效的 jti。每次 JwtFilter 验签完再比对当前 jti 是否等于这个 key 里存的 jti。如果管理员在后台踢出某个设备就把这个设备的 key 删除同时把 jti 拉黑。下一个请求过来过滤器发现 key 不存在或 jti 不匹配直接返回 401。如果不想支持多端同时在线就只允许一个 key用online:{userId}覆盖旧 jti新登录会把旧设备顶下去。这个逻辑完全在 Redis 里完成比在 session 里记录设备列表要清爽得多。5.4 后续接网关 / 微服务的平移方法如果你的系统接下来要拆微服务这套 Shiro JWT 的改造不会白费。最常见的做法是网关统一验签网关把 Authorization 头的 JWT 解析一遍取出 userId然后向后端服务透传X-User-Id这个内部头。下游服务不再解析 JWT只在 Shiro 的 Realm 里信任内部头并加载用户权限。每个服务可以保留独立的 Realm 和授权缓存只是 doGetAuthenticationInfo 不再验签而是根据信任的头信息构造主体。要注意的是网关到下游服务之间的网络必须可信否则任何人都能伪造X-User-Id直接绕过认证。我在项目里是内网独立网络段 网关强校验两层一起保证安全。每次有人问我 Shiro 整合 JWT 最容易被忽略的是什么我的回答都是不是加密算法也不是过滤器顺序而是让 Shiro 在无状态模式下真正“安静下来”。你用 Shiro是为了继续用它的 Subject、Realm、权限注解你引入 JWT是为了忘掉 Session。这两个目标不冲突但你必须在配置里明确告诉 Shiro 这是无状态环境。顺手留一个调试习惯本地跑起来以后先用 curl 手带 Authorization 头访问几个受保护接口观察响应头有没有 Set-Cookie。如果有说明 session 还在偷偷创建尽早处理。这个动作每次改造完我都会做一遍成本很低却能把很多隐性问题提前挡在门外。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑