Shiro整合JWT实战:从Session到无状态认证的完整拆解 📅 发布时间:2026/9/9 14:54:59 👁 浏览次数: 简介一套围绕Spring Boot结合Shiro与JWT实现无状态身份认证的源码包内容与《SpringBootShiroJWT整合详解》讲解节奏一一对应旨在帮助后端开发者解决传统Session登录在前后端分离、微服务场景下难以扩展的痛点。方案覆盖Shiro认证授权、JWT令牌签发与校验、过滤器链放行规则等核心环节适合具备一定Java基础并希望落地统一权限控制的读者深度研读。压缩包总共有26个文件包括8个Java源文件、8个Class字节码文件、7份XML配置、2份YAML配置以及1个工程描述文件整体仅26KB结构精简方便在IDE中快速导入核对。目前已有543人学习或下载热度反映出该整合路径在项目中的实用价值。资源内代码层次分明ShiroConfig负责定义安全拦截规则JwtToken让Shiro原生支持JWT身份标识JwtUtil完成令牌生成与解析Controller、Service、Mapper等则串联起完整的登录授权演示流程附带编译产物还能辅助理解源码与字节码的对应关系是一份可直接参照实现的Spring Boot安全整合案例。 收到这个shiro_jwt.rar的时候我正在帮朋友看一个老项目的权限模块。解压之后扫了一遍目录做法其实很典型Apache Shiro 负责认证授权JWT 做无状态令牌两者通过自定义 Filter 串起来。这个东西在前后端分离、移动端接口、微服务化的场景里几乎是标配思路但网上能直接跑通的完整例子没那么多坑倒是不少。这篇就是我基于这个压缩包项目整理出的完整拆解包含核心代码、配置、联调过程和实战中踩过的几个坑适合准备把传统 Session 登录改成 JWT 认证、或者想搞懂 Shiro 和 JWT 怎么配合的人参考。1. Shiro 和 JWT 绑在一起到底解决了什么问题1.1 Shiro 默认的 Session 机制在接口化场景下有多别扭Shiro 本身是一套完整的安全框架默认基于 Session 管理登录状态。登录成功后用户身份存在服务器端 Session 里浏览器靠 Cookie 里的JSESSIONID找到这份身份。单体应用里这套方案没毛病但换成前后端分离之后就很尴尬前端可能部署在另一个域名下Cookie 跨域麻烦App 端没有 Cookie 概念每次请求还得手动维护会话标识后端一旦水平扩展Session 存在 A 节点请求被负载均衡转发到 B 节点就找不到了非要引入 Redis 做 Session 共享才能继续玩。这套痛点几乎每个写过接口项目的人都遇到过。压缩包里把 Shiro 换掉不现实毕竟Realm、Permission、Subject这套授权模型还是好用的。合理的做法就是保留 Shiro 的认证授权骨架把身份怎么传这部分替换掉。1.2 JWT 在这里的角色只是一个身份载体JWT 的全称是 JSON Web Token说白了就是一段自包含的、经过签名的字符串。它由三段组成Header 声明签名算法和类型Payload 放用户标识、过期时间、签发时间这些声明Signature 是服务端用密钥对前两段算出来的签名。服务端收到令牌后验签通过、过期时间没过就信任里面的用户身份。这就意味着服务端不需要存 Session天然适合无状态接口。但这个压缩包的架构并没有让 JWT 取代 Shiro 的认证逻辑。正确理解是Shiro 仍然是安全框架负责这个用户是谁、能不能访问、有哪些权限JWT 只是把每次请求的身份凭证从 CookieSessionId 换成了一个可验签的令牌。Shiro 的Subject依然存在只是它不再从 Session 里取身份而是从 JWT 解析出来的结果里拿。1.3 一条完整的认证链路是这样的拿这个项目来说一次请求从进来到结束大概是这样的顺序客户端带着Authorization: Bearer token访问任意接口。请求被 Shiro 的过滤器链拦截自定义的 JWT Filter 先从请求头里取出 token。Filter 把 token 包装成一个自定义的JwtToken交给SecurityManager去登录。SecurityManager把 token 交给配置好的UserRealmRealm 的doGetAuthenticationInfo解析 JWT、查库确认用户存在且状态正常。认证成功后当前线程的Subject就持有用户身份后续授权操作像RequiresPermissions就能正常用。整个过程中服务端没有创建任何 Session身份完全靠每次请求携带的 JWT 来重建。这个链路理解透了后面看代码就是顺水推舟的事。2. 解压出来的项目骨架先看这几个文件2.1 目录结构和关键类这个 rar 是一个典型的 Spring Boot 单模块 Maven 工程核心代码都在src/main/java下。我建议拿到压缩包先别急着跑按下面这个顺序读代码com.example.shirojwt ├── config │ ├── CorsConfig.java │ └── ShiroConfig.java ├── controller │ └── AuthController.java ├── filter │ └── JwtFilter.java ├── model │ ├── Result.java │ ├── LoginVO.java │ └── User.java ├── realm │ └── UserRealm.java ├── service │ ├── UserService.java │ └── impl/UserServiceImpl.java ├── util │ └── JwtUtil.java └── ShiroJwtApplication.java包结构很清楚Filter 和 Realm 是关键配置类是粘合剂。这样分层的意义在于别人接手的时候能一眼看出认证入口Filter、认证逻辑Realm、业务接口Controller分别在哪我强烈建议你自己的项目也保持这个风格。2.2 Maven 依赖的版本组合pom.xml里最核心的依赖是这三组依赖版本作用spring-boot-starter-web2.5.xWeb 基础能力shiro-spring-boot-web-starter1.8.0Shiro 与 Spring Boot 整合jjwt0.11.5JWT 的生成与解析这里有个很实际的版本问题jjwt不要用旧版 0.9.x那个已停止维护而且 0.11.5 的 API 和旧版完全不同网上很多老博客的写法在 0.11.x 下直接编译不过。另外 Shiro 1.6 和 Spring Boot 2.6 组合时会遇到路径匹配策略改变导致 Shiro 过滤规则失效的问题所以项目宁可先用 Spring Boot 2.5也不要盲目升版本。如果一定要用 Spring Boot 2.6记得在application.yml里加一句spring.mvc.pathmatch.matching-strategy: ant_path_matcher。2.3 application.yml 里的关键配置这个项目的配置写在application.yml核心内容就两块server: port: 8080 shiro-jwt: secret: replace-this-with-a-random-string-at-least-32-chars expire-time: 7200000secret是 JWT 签名密钥expire-time是令牌过期时间单位毫秒这里 7200000 就是 2 小时。注意 jjwt 0.11.x 用HMAC签名时密钥长度必须至少 32 字节否则启动后一生成 token 就会抛WeakKeyException。这个坑我当年踩过第一次跑起来是因为 JWT 解析代码在登录接口才触发反应了半天才反应过来。3. 核心链路拆解JWT 工具类、Realm、Filter 和配置是怎么串起来的3.1 JwtUtil生成和解析都在这项目把 JWT 的操作全部收敛在JwtUtil里业务代码不直接接触 jjwt API。核心方法就三个生成、解析、验签。示例代码大致长这样public class JwtUtil { private static final String SECRET replace-this-with-a-random-string-at-least-32-chars; private static final long EXPIRE_TIME 7200000L; private static final SecretKey KEY Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); public static String createToken(String username) { long now System.currentTimeMillis(); return Jwts.builder() .setSubject(username) .setIssuedAt(new Date(now)) .setExpiration(new Date(now EXPIRE_TIME)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static String getUsername(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody() .getSubject(); } public static boolean isExpired(String token) { Date expiration Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody() .getExpiration(); return expiration.before(new Date()); } }生产环境里SECRET和EXPIRE_TIME不要写死用ConfigurationProperties读配置文件。createToken里还可以顺手把用户的userId、角色标识加进自定义 claim后面做多端登录和权限刷新会用到后面第六节再展开。3.2 JwtToken 和 UserRealmShiro 原来的UsernamePasswordToken装的是用户名密码这没法承接 JWT 令牌。所以项目里自定义了一个JwtToken实现AuthenticationTokenpublic 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是因为认证信息完全可以从 token 里解析出来不需要第二个凭证。如果严格一点也可以让getPrincipal返回用户名getCredentials返回 token但那样需要在 Filter 里先解析一次增加重复代码。这个项目直接统一返回 token逻辑更简单。接着看UserRealm。它有两个核心方法需要重写public class UserRealm extends AuthorizingRealm { private final UserService userService; public UserRealm(UserService userService) { this.userService userService; setCredentialsMatcher(new JwtCredentialsMatcher()); } Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { String jwt (String) token.getPrincipal(); String username JwtUtil.getUsername(jwt); User user userService.findByUsername(username); if (user null || !user.isEnabled()) { throw new UnknownAccountException(用户不存在或已被禁用); } return new SimpleAuthenticationInfo(username, jwt, getName()); } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); User user userService.findByUsername(username); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.addRoles(user.getRoles()); info.addStringPermissions(user.getPermissions()); return info; } }这里有一个很多人容易忽略的细节doGetAuthenticationInfo里我没有再去校验密码。因为 JWT 本身是服务端签发的验签在JwtUtil.getUsername的时候已经完成token 合法就代表身份可信。但用户是否被禁用、是否被删除这个必须每次请求都查库否则被删号的用户拿着旧 token 还能访问接口这是无状态认证最常见的隐患。3.3 JwtFilter请求入口的身份闸门JWT Filter 是整个链路里最重要的一环。这个项目继承的是AuthenticatingFilter它已经封装了尝试登录的骨架我们只要覆盖几个方法public class JwtFilter extends AuthenticatingFilter { Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { String token getTokenFromHeader((HttpServletRequest) request); return token null ? null : new JwtToken(token); } Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (((HttpServletRequest) request).getMethod().equalsIgnoreCase(OPTIONS)) { return true; } return ((HttpServletRequest) request).getRequestURI().contains(/api/login); } Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { String token getTokenFromHeader((HttpServletRequest) request); if (token null) { response401(response, 未携带token); return false; } try { getSubject(request, response).login(new JwtToken(token)); return true; } catch (AuthenticationException e) { response401(response, token无效或已过期); return false; } } Override protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) { return true; } private String getTokenFromHeader(HttpServletRequest request) { String header request.getHeader(Authorization); if (StringUtils.hasText(header) header.startsWith(Bearer )) { return header.substring(7); } return null; } }几个关键点说下。isAccessAllowed里放行 OPTIONS是为了跨域预检请求不被拦。onAccessDenied是真正的认证入口token 不存在直接返回 401存在就调用subject.login把认证推进到 RealmRealm 里抛出的任何异常都会被捕获统一转成 401 JSON。这里千万不能调用父类的saveRequestAndRedirectToLogin那会把接口请求重定向到登录页我要的是接口化的 JSON 错误提示。3.4 ShiroConfig把前面的零件组装起来没有配置Realm、Filter 都是散落的零件。ShiroConfig做的事情就是创建SecurityManager、把 Realm 放进去、把 JwtFilter 注册到 Shiro 的过滤链上、定义哪些路径放行哪些路径需要认证Configuration public class ShiroConfig { Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, JwtFilter jwtFilter) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); factoryBean.setLoginUrl(/api/401); MapString, Filter filters new HashMap(); filters.put(jwt, jwtFilter); factoryBean.setFilters(filters); MapString, String chainMap new LinkedHashMap(); chainMap.put(/api/login, anon); chainMap.put(/api/401, anon); chainMap.put(/**, jwt); factoryBean.setFilterChainDefinitionMap(chainMap); return factoryBean; } Bean public SecurityManager securityManager(UserRealm userRealm) { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(userRealm); return manager; } }过滤规则用LinkedHashMap是有讲究的Shiro 按照 Map 的插入顺序从上往下匹配先命中的规则生效。所以/api/login的 anon 放行必须写在/**之前否则登录接口也被 JWT 过滤器拦掉了。这是老手都容易犯的错。3.5 登录接口和统一返回结构登录接口其实简单真正干活的是密码校验和 token 签发RestController RequestMapping(/api) public class AuthController { private final UserService userService; PostMapping(/login) public Result login(RequestBody LoginVO vo) { User user userService.findByUsername(vo.getUsername()); if (user null || !userService.checkPassword(vo.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } String token JwtUtil.createToken(user.getUsername()); return Result.ok(Collections.singletonMap(token, token)); } }这里的密码校验用的是 BCrypt数据库里存的是哈希后的密文不要明文存储。登录成功后只返回 token返回结构统一用Result包装code、message、data三个字段前端对接起来也清晰。4. 本地跑一遍验证完整链路4.1 登录拿到 token项目启动后先发一个登录请求curl -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}正常响应{ code: 200, message: success, data: { token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIs... } }我拿在线解析工具拆过这段 tokenPayload 里确实有subadmin、exp过期时间、iat签发时间三个字段签名部分稍微改动一个字符解析就会失败。4.2 带 token 访问受保护接口把上一步的 token 放到请求头里curl http://localhost:8080/api/user/info \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...能正常返回用户信息。这个接口本身没有加权限注解只要 token 合法就能访问。再测试带权限注解的接口比如项目里RequiresPermissions(user:write)标记的创建用户接口。如果登录用户没有对应权限Shiro 会抛出AuthorizationException最终被全局异常处理器转成 403 JSON。这一步能同时验证认证和授权两个环节。4.3 无 token、错 token、过期 token 的表现无 token 的情况JwtFilter 直接返回 401。把 token 的最后一个字符改掉再请求签名校验失败同样返回 401。把过期时间改短重新登录等两小时后请求返回的也是 401。这里我想强调一个设计细节返回给前端的错误提示对无 token和token 无效可以分开说但不要告诉客户端具体是因为签名错误还是过期避免被拿来探测。统一返回未认证或登录已过期更安全让前端收到 401 后自动跳回登录页。5. 跑 demo 时最容易踩的五个坑5.1 过滤器顺序CORS 和 JWT 谁先执行前端项目接这个后端时第一个报错往往是跨域。很多人的第一反应是加CrossOrigin但加了还是报错因为 Shiro 的过滤器链执行在 Spring MVC 的跨域处理之前预检请求还没到 Controller 就被 JwtFilter 拦住了。我实际验证过两种可靠解法。一种是在 JwtFilter 的isAccessAllowed里直接放行 OPTIONS前面代码已经做了同时自己写一个CorsFilter放在 Shiro 过滤器之前。另一种是干脆不用 Spring 的 CORS 方案直接在 JwtFilter 里对响应头设置Access-Control-Allow-Origin等字段。我更喜欢第一种配置分离职责清晰。5.2 未认证请求被重定向成了 302默认情况下Shiro 遇到未认证的请求会跳转到loginUrl浏览器拿到 302 而不是 401。接口项目里这是完全不可接受的。解决办法就是在onAccessDenied里写死返回 JSON不要调用父类的重定向逻辑。配置里的setLoginUrl(/api/401)只是兜底真正起作用的还是自定义 Filter 里的response401方法。5.3 过滤规则里/api/login放行失效如果你把过滤链写成chainMap.put(/api/login, anon); chainMap.put(/api/**, jwt); chainMap.put(/**, jwt);看起来没问题但实际/api/login会被第二条/api/**规则覆盖吗不会。Shiro 是顺序匹配第一条命中就直接结束。失效的常见原因反而是你没用LinkedHashMap而是用了HashMap导致插入顺序被打乱/**跑到了前面。这个坑很隐蔽排查的时候可以先打印一遍filterChainDefinitionMap看看实际匹配顺序。5.4 Spring Boot 2.6 路径策略导致规则全部失效Shiro 1.6 之前的AntPathMatcher和 Spring Boot 2.6 默认的PathPatternParser是两套东西。如果项目被迫升级到 Spring Boot 2.6Shiro 的/**这些规则可能全部匹配不上表现为所有接口都能匿名访问安全配置形同虚设。这个项目里用的是 Spring Boot 2.5躲开了这个问题但你接手其他项目时务必检查spring.mvc.pathmatch.matching-strategy。5.5 改了权限但用户还是要等缓存过期才能生效Shiro 的AuthorizingRealm默认有认证和授权缓存第一次查询某个用户的权限后结果会缓存起来。业务方改了该用户的角色权限如果直接清缓存或者依赖默认过期时间生产环境可能十几分钟甚至更久不生效。压缩包项目里为了演示把缓存关掉了实际生产建议用 Redis 作为缓存管理器然后在修改用户权限的接口里主动调用realm.clearAuthorizationCache。这块第六节再细说。6. demo 之外生产落地至少要补三件事6.1 token 自动续签demo 里 token 过期就是过期用户只能重新登录。生产环境没人受得了 2 小时后写了一半的表单突然跳登录页。常见的续签方案有两种我推荐轻量一点的滑动过期。做法是在 Filter 里拿到 token 后先从 JWT 的 claims 里取exp判断是否已经进入过期临近期比如剩余时间不足总有效期的一半。如果是就签发一个新 token放到响应头X-Token里返回。前端拦截器检测到这个响应头就替换本地存储的 token用户完全无感知。这样做的代价是每次进入临近期都会多签发一次对性能影响可以忽略。注意不要每次请求都签新 token否则老 token 在黑名单失效之前始终可用会带来安全问题。如果还要更严谨用双 token 方案一个短期的 access token 用于访问接口一个长期的 refresh token 用于换取新的 access token。代价是复杂度增加短期项目其实没必要一上来就上。6.2 退出登录和 token 黑名单JWT 无状态意味着服务端无法主动让某个 token 失效。但退出登录后 token 还能用在多数业务里是不可接受的。折中方案是维护一个 Redis 黑名单用户点退出时解析当前 token 的jti一个唯一标识把它写入 Redis过期时间设为 token 剩余有效期。Filter 里每次先查一下jti是否在黑名单存在就拒绝。这个方案不是严格的无状态了但只多了一次 Redis 查询换来的是可控的退出时效我认为在绝大多数内部系统中是值得的。6.3 多端登录控制如果产品要求一个账号只能在一处登录可以在签发 token 时把随机jti存进 Rediskey 是login:token:{userId}value 是当前签发的最新jti。每次请求在 Filter 里校验 token 的jti是否等于 Redis 里的最新值不一致就拒绝。用户在新设备登录后旧设备的 token 自然失效体验就像被顶下线。顺带说权限刷新的落地方式。用了 Redis 缓存管理器之后clearAuthorizationCache需要从容器里拿到 Realm 实例再调用。如果项目里用的是RequiresPermissions注解在权限变更接口里执行一次清理即可UserRealm realm (UserRealm) applicationContext.getBean(userRealm); realm.clearAuthorizationCache(username);这样角色授权改动后下一个请求进来就能读到新权限不用等缓存自然过期。最后再分享一点个人体会。这个 shiro_jwt 项目把核心流程压缩得很干净十几分钟就能跑通但从 demo 到上线之间的距离往往就藏在你有没有想清楚无状态这两个字的代价。JWT 让接口变爽了可你在享受便利的同时必须自己解决过期续签、主动失效、权限实时性这些原本 Session 替你扛着的问题。我当时第一次跑通这个项目的时候也很兴奋直到压测时才意识到每次请求查一次数据库确认用户状态、每次授权都要扫描一遍权限字符串性能开销并不低。所以如果你要抄这个方案我建议至少把两点加进去一是给权限查询加缓存二是把 JWT 密钥放到配置中心管理并且定期轮换。框架解决的是能不能用而好不好用永远是你自己在业务边界上判断出来的。本文还有配套的精品资源点击获取