前后端分离的项目做了不少,但每次提到权限认证,还是有不少团队直接抄一个 Shiro 或者 Spring Security 的配置就往上堆。真正跑起来之后,跨域、Token 失效、过滤器顺序、权限缓存不刷新,各种问题轮着来。这篇我就拿一个实际做过的项目来拆解,讲讲 Spring Boot + Vue + Shiro 这套组合在前后端分离场景下到底该怎么设计、怎么落地,以及哪些坑是文档里不会写、但实际一定会踩的。
先说清楚这套方案解决的核心问题:前端走 SPA 单页应用,后端提供纯 JSON 接口,两者彻底分离部署。传统基于 Session 的认证方式在这套架构下会出现跨域携带 Cookie 困难、CSRF 防护复杂、服务端无法水平扩展等问题。Shiro 本身虽然是基于 Session 的安全框架,但通过改造可以完美适配无状态 Token 认证模式。我这里采用的是 Shiro + JWT 的方案,前端把 Token 放在请求头里,后端通过自定义过滤器解析校验,既不依赖 Session,也能保留 Shiro 的 RBAC 权限控制能力。
这套方案适合以下几种人群:正在做前后端分离项目但权限模块还没想清楚怎么落地的开发者;已经用 Shiro 做过传统服务端渲染项目、想迁移到前后端分离架构的团队;以及想了解 JWT 无状态认证在真实项目中如何与权限框架结合的人。下面我直接从设计思路开始拆。
1. 整体设计:前后端分离下权限认证的核心矛盾与解法
1.1 传统 Session 认证在前后端分离架构下的失效点
先说一个很多人没意识到的问题:前端项目部署在静态服务器(Nginx、OSS 等),后端跑在独立应用服务器上,两者域名不同,这就产生了跨域。浏览器跨域请求默认不会携带目标域名的 Cookie,虽然可以通过 CORS 配置Access-Control-Allow-Credentials: true来解决,但这个东西涉及前端withCredentials、后端允许指定来源域名等一系列配置,稍有不慎就会把整个接口暴露出去。
更麻烦的是 Session 本身的状态特性。用户登录后,认证状态保存在服务器内存里,如果想做多实例部署,就得引入 Session 共享(Redis 方案)。这还不算完,Session 模式下 CSRF 防护几乎是必须的,因为浏览器会自动带上 Cookie,攻击者可以诱导用户发起恶意请求。前后端分离场景下,接口全是 JSON,防 CSRF 的成本和复杂度进一步上升。
我一直觉得 Session 不是不能用,而是它在前后端分离架构里天然处于劣势。我们需要的是一个无状态的认证机制:服务器不保存会话状态,客户端每次请求时把身份凭证带过来,服务端校验通过就放行。这就是 JWT 的基本思路。
1.2 技术选型:为什么选 Shiro 而不是 Spring Security
做技术选型的时候,很多人第一反应是 Spring Security,毕竟它是 Spring 官方生态的一员,跟 Spring Boot 配合得天衣无缝。但我的经验是:如果项目权限模型是标准 RBAC(用户-角色-权限),Shiro 的学习成本和接入成本都低得多,而且灵活度也很高。
Shiro 的核心抽象非常干净:Subject(当前用户)、SecurityManager(安全管理器)、Realm(数据源),三个概念就能覆盖绝大多数场景。Spring Security 功能强大,但它的过滤器链体系、认证管理器、授权管理器、方法安全注解这些概念叠在一起,新手光是把配置跑通就要花不少时间。而 Shiro 这边,一个自定义 Realm 加上一个过滤器,大部分需求就搞定了。
还有个实际考量:Shiro 对无状态认证的支持方式更加直观。它允许你自定义 AuthenticationToken 和 Realm 的认证逻辑,你只需要把 JWT 字符串封装成一个自定义 Token 对象,在 Realm 里做校验和解密,就能完全绕过 Session。Spring Security 也能做,但配置项多,对于中小型团队来说学习曲线偏陡。
我用 Shiro 还有一个理由:它的授权注解非常轻量。@RequiresPermissions、@RequiresRoles直接标在接口方法上,配合 AOP 拦截,不需要像 Spring Security 那样配置一堆@EnableGlobalMethodSecurity的细节。对于接口权限的精细化控制,Shiro 的注解用起来非常顺手。
1.3 整体架构与请求流转链路
这套方案的请求流转链路是这样的:前端发起请求 → 携带Authorization: Bearer <token>请求头 → 后端经过 Shiro 过滤器链 → JWT 过滤器拦截并解析 Token → 如果 Token 有效,则通过自定义 Realm 查询用户权限并构造 Subject → 进入 Controller 接口 → 如果接口上有权限注解,则做权限校验 → 返回数据。
登录接口是唯一的例外,它走的是另一种逻辑:用户提交用户名密码 → 后端验证 → 生成 JWT Token → 返回给前端 → 前端存储 Token。后续所有请求都走上面的链路。
这里有一个关键点:Shiro 的过滤器链里必须把登录接口和匿名访问的接口(比如验证码、公开查询接口)配置为anon,其余接口走jwt过滤器。如果配置不当,会出现登录接口本身被拦截的尴尬局面。
2. 核心机制拆解:JWT 与 Shiro 的无缝整合原理
2.1 JWT 的结构与 Token 设计
JWT 实际上是一个经过签名的 JSON 字符串,分三段:Header(头部)、Payload(负载)、Signature(签名)。Header 里存放算法信息,Payload 里放业务数据,Signature 是对前两段的签名。
在设计 Token 的时候,我一般往 Payload 里放四个字段:userId(用户主键)、username(用户名)、exp(过期时间)、jti(JWT 的唯一 ID)。有些教程喜欢把角色和权限也塞进 Token,我不建议这么做。原因有两点:
第一,Token 是客户端可见的,虽然签名保证了它不能被篡改,但 Payload 里的内容是可以被解密的。把权限列表放进 Token,等于把系统权限结构暴露给客户端。
第二,权限是会变的。用户角色调整、权限收回,这些操作一旦发生,如果 Token 里嵌了权限列表,那么当前已经签发的 Token 仍然带着旧权限,这是很不安全的。
正确做法是:Token 里只放用户身份标识,权限信息由后端在 Realm 中实时查询。这样权限变更可以立即生效,不用等 Token 过期。
Token 过期时间也是个需要权衡的点。设置太短,用户要频繁重新登录,体验差;设置太长,Token 泄露的风险窗口变大。我常用的策略是 2 小时过期,前端在检测到 401 响应时,用 Refresh Token 换新的 Access Token。
2.2 Shiro 的认证流程如何在无状态模式下工作
Shiro 的认证流程本来是为 Session 设计的:Subject 登录 → SecurityManager 调用 Authenticator → 调用 Realm 的doGetAuthenticationInfo获取用户信息 → 比对密码。在无状态模式下,这个流程被我们改造成了:
JWT 过滤器拦截请求 → 从请求头取出 Token → 构造一个自定义的JwtToken对象(SimpleAuthenticationToken 的子类)→ 调用Subject.login(JwtToken)→ SecurityManager 走到 Realm → 在doGetAuthenticationInfo中解析并校验 Token → 返回用户信息 → 认证通过,Subject 建立。
重点在于:整个流程中不产生 Session,Subject 的状态只在当前请求线程中有效。而通常需要在每次请求中重新认证。不过 Shiro 在一次请求内,Subject 会被缓存到 ThreadContext 里,同一请求多次调用 Subject 不会重复认证,所以性能上没问题。
为了保证这一点,必须设置SecurityManager的会话模式为无状态:sessionManager.setSessionDAO(new MemorySessionDAO())或直接禁用 Session 的创建。下面代码中我会展示具体配置。
2.3 权限控制的两种粒度:注解与动态菜单
权限控制我一般分成两层:接口层的权限校验用 Shiro 的注解,前端菜单和按钮的显示控制通过后端返回的权限标识数组。
接口层的逻辑很简单:在需要控制的 Controller 方法上加@RequiresPermissions("user:add"),Shiro 会在方法执行前检查当前 Subject 是否拥有该权限标识,如果没有就抛出AuthorizationException,然后由全局异常处理器转换为 403 响应。
前端菜单这块容易踩坑。我的做法是:登录成功后,后端返回用户信息时同时返回他拥有的权限标识数组(如["user:add", "user:update", "role:list"]),前端根据这个数组动态生成路由的meta.permissions,再配合 Vue Router 的守卫做路由拦截。按钮级别的隐藏用自定义指令,比如v-permission="'user:delete'",没有权限就不渲染按钮。
这样设计的好处是权限的最终判断始终在后端,前端只是做展示层的过滤。就算有人绕过前端直接调接口,后端依然会拦截,这才是安全的底线。
3. 实操落地:Spring Boot + Vue 的核心代码实现
3.1 项目结构与 Maven 依赖配置
先看后端项目的依赖配置,这里我用的是 Spring Boot 3.x 的版本配套 Shiro 1.13。有一个细节需要提前说明:Shiro 1.x 对 Spring Boot 3 的兼容是通过shiro-spring-boot-starter的特定版本来实现的,我第一次搭的时候也排查了好一阵,但实际用下来可以跑通。
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.13.0</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>JWT 的库我选的是 JJWT,这个库提供了完整的 JWT 创建、解析、签名校验 API,而且对 JWT 规范的支持很全面。有些项目会用 hutool 的 JWT 工具类,也可以,但我还是建议用专门的库,功能更完整。注意 jjwt 的impl和jackson依赖一定要加runtime作用域,否则打包会出问题。
3.2 Shiro 核心配置类(无状态会话 + JWT 过滤器链)
这是整个方案中最关键的一个类。我在这个配置类里做了四件事:创建自定义 JWT 过滤器、配置 SecurityManager(包括禁用 Session)、配置过滤链规则、注册生命周期 Bean。
@Configuration public class ShiroConfig { @Bean public JwtFilter jwtFilter() { return new JwtFilter(); } @Bean public AuthorizingRealm jwtRealm() { return new JwtRealm(); } @Bean public DefaultWebSecurityManager securityManager(AuthorizingRealm jwtRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(jwtRealm); // 禁用会话,实现无状态 DefaultSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionValidationSchedulerEnabled(false); sessionManager.setSessionDAO(new MemorySessionDAO()); securityManager.setSessionManager(sessionManager); securityManager.setRememberMeManager(null); return securityManager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager, JwtFilter jwtFilter) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 注册自定义过滤器,名字叫 jwt Map<String, Filter> filters = new HashMap<>(); filters.put("jwt", jwtFilter); factoryBean.setFilters(filters); Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); // 放行登录接口和公开接口 filterChainDefinitionMap.put("/api/login", "anon"); filterChainDefinitionMap.put("/api/captcha", "anon"); filterChainDefinitionMap.put("/api/public/**", "anon"); // 其他接口走 JWT 过滤器 filterChainDefinitionMap.put("/**", "jwt"); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } }这里有一个非常关键的点:filterChainDefinitionMap必须用LinkedHashMap,因为 Shiro 的过滤器链是按顺序匹配的,第一个匹配到的规则生效。如果/**放在前面,后面的anon规则就永远不会执行。这个顺序问题我见过不止一次出现在生产事故中。
setRememberMeManager(null)也值得说明。RememberMe 功能默认会向客户端写入 Cookie,这在前后端分离的场景下会带来不必要的 CSRF 风险,而且无状态模式下也用不到 RememberMe,直接禁掉最省心。
3.3 自定义 JwtFilter 拦截逻辑
JWT 过滤器是整个链路的入口,它决定了一个请求是否要走 Shiro 的认证流程。我的实现逻辑是:从请求头里取 Token,取不到就直接放行(Shiro 会在后续的 Realm 校验中因为没有 Token 而判定未认证),这听上去有点违反直觉,但这是 Shiro 的典型用法:过滤器负责「检查有 Token 就交给 Shiro 认证」,Shiro 的isAccessAllowed控制最终是否放行。
public class JwtFilter extends AuthenticatingFilter { @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; String token = httpRequest.getHeader("Authorization"); if (StringUtils.hasText(token)) { // 兼容 Bearer 前缀 if (token.startsWith("Bearer ")) { token = token.substring(7); } return new JwtToken(token); } return null; } @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { // 没有 Token 的请求,直接拒绝,返回 401 HttpServletRequest httpRequest = (HttpServletRequest) request; String token = httpRequest.getHeader("Authorization"); if (!StringUtils.hasText(token)) { writeUnauthorized(response); return false; } try { executeLogin(request, response); return true; } catch (Exception e) { writeUnauthorized(response); return false; } } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { return false; // 不重定向,直接返回 } }createToken方法的作用是从请求中提取凭证并封装成 Shiro 的AuthenticationToken。isAccessAllowed是我重新定义的核心逻辑:传统 Shiro 过滤器的默认行为是「未认证就跳转登录页」,但在前后端分离项目里,我们不需要跳转,而是返回 401 JSON。前端收到 401 就知道 Token 失效或未登录,可以跳转到登录页。
有一个坑需要提前说:这个过滤器继承了AuthenticatingFilter,它的父类里自带了一个onAccessDenied方法,默认会使用createToken获取 Token 并调用executeLogin。我当时一开始没有重写isAccessAllowed,导致写法不直观,代码逻辑混乱。后来把认证入口统一放在isAccessAllowed中处理,逻辑清晰多了。所以这段代码是经过整理后的版本,建议直接按这个思路去写。
3.4 自定义 JwtRealm:身份认证与授权查询
Realm 是 Shiro 连接业务数据的桥梁,它承担两个职责:认证时根据 Token 找到用户,授权时根据用户 ID 查询权限标识。
public class JwtRealm extends AuthorizingRealm { @Autowired private UserService userService; @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Long userId = (Long) principals.getPrimaryPrincipal(); Set<String> roles = userService.listRoles(userId); Set<String> permissions = userService.listPermissions(userId); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setRoles(roles); info.setStringPermissions(permissions); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String jwtToken = (String) token.getPrincipal(); // 1. 解析 Token Claims claims = JwtUtil.parseToken(jwtToken); if (claims == null) { throw new AuthenticationException("Token 无效或已过期"); } // 2. 根据用户 ID 查询用户 Long userId = claims.get("userId", Long.class); User user = userService.getById(userId); if (user == null || user.getStatus() != 1) { throw new UnknownAccountException("用户不存在或已被禁用"); } // 3. 返回认证信息 SimpleAuthenticationInfo authInfo = new SimpleAuthenticationInfo( userId, // Principal,之后可以从 Subject 中获取 jwtToken, // Credentials,这里不参与二次密码校验 getName()); return authInfo; } }supports方法必须重写,它决定了 Realm 能够处理哪种 Token。默认的UsernamePasswordToken走的是密码逻辑,我们的JwtToken必须声明supports,否则 Shiro 会报「没有 Realm 支持该 Token」。
还有一个细节:在认证过程中,Shiro 默认会用CredentialsMatcher对 Token 里的 Credentials 做二次比对。因为我们的 Credentials 放的是 JWT 字符串本身,比对没有意义,所以我把CredentialsMatcher设为直接返回 true。这一步很简单:
public JwtRealm() { setCredentialsMatcher((token, info) -> Boolean.TRUE); }3.5 JWT 工具类与登录接口
JWT 工具类负责生成、解析 Token,以及处理刷新逻辑。签名密钥我建议配置在application.yml里,不要写死在代码中。密钥的长度有讲究:HS256 算法要求密钥至少 256 位(32 字节),密钥太短在签名时会直接报错。
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-time}") private long expireTime; // 单位:毫秒 public String generateToken(Long userId, String username) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expireTime); return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8)) .compact(); } public Claims parseToken(String token) { try { return Jwts.parser() .setSigningKey(secret.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | SignatureException e) { return null; } } }登录接口的逻辑就简单了:接收用户名密码 → 调用Subject.login(new UsernamePasswordToken(...))→ 这个逻辑可以临时走一个专门用于登录的 Realm 或直接查询数据库比对 → 生成 JWT 返回给前端。
我这里稍微简化了一下,把登录逻辑直接用 Service 层完成,没有走 Shiro 的登录流程。因为登录接口本身已经被放行为anon,用户提交的用户名密码认证是一次性的,直接用业务代码校验再签发 Token,反而更直接。需要注意:登录接口成功后,要给前端返回用户基本信息和权限列表,前端用来渲染菜单。
3.6 前端 Vue 代码:Axios 拦截器与路由守卫
前端这块,核心就是 Token 的管理。我在项目里采用localStorage保存 Token,Axios 拦截器负责在请求头中附加 Authorization,响应拦截器负责统一处理 401。
// request.js import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => Promise.reject(error)) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') // 清除用户信息后跳转到登录页 window.location.href = '/login' } return Promise.reject(error) } )路由守卫的作用是防止未登录用户直接通过 URL 访问受保护的页面:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })这里有个进阶的需求:动态路由。如果项目后台支持动态菜单,还需要在路由守卫中判断用户权限是否已加载,未加载的话先请求后端获取菜单和权限,使用router.addRoutes动态添加路由表。这一步比较复杂,后面我会单独讲一个坑:动态路由在白屏刷新后失效的排查过程。
4. 实操过程:从零到可用的完整落地步骤
4.1 步骤一:搭建基础项目骨架
后端的骨架没有什么新颖之处,标准的 Spring Boot 项目,包结构按controller、service、mapper、entity、config、filter、realm、util分层。但我要特别强调一个约定:所有需要权限控制的接口必须使用统一的/api前缀。这样在配置 Shiro 过滤链时,只需要对/api/**做拦截就可以了,Swagger 文档、静态资源、健康检查等路径都不会受 Shiro 干扰。
前端骨架建议直接使用 Vue CLI 或 Vite 创建 Vue 3 项目,配置vue-router和pinia(状态管理)。注意baseURL配置为/api,然后在开发环境下使用代理解决跨域问题,生产环境由 Nginx 反向代理统一转发。这样做的好处是:前端代码里不需要写后端的完整域名,换环境只需要改 Nginx 配置。
4.2 步骤二:数据库设计与权限模型
权限模型我用的是经典的 RBAC 五表结构:用户表(sys_user)、角色表(sys_role)、菜单权限表(sys_menu)、用户角色关联表(sys_user_role)、角色菜单关联表(sys_role_menu)。sys_menu 表里存的不只是菜单,还包括按钮级别的权限标识(perms字段,比如user:delete)。
这个表结构不算复杂,但它直接影响到 Realm 里的授权查询。我在UserService里封装了listPermissions(userId)方法,内部通过三表联查获取权限标识集合:
SELECT DISTINCT m.perms FROM sys_menu m INNER JOIN sys_role_menu rm ON m.id = rm.menu_id INNER JOIN sys_user_role ur ON rm.role_id = ur.role_id WHERE ur.user_id = #{userId} AND m.perms IS NOT NULL AND m.perms != ''这套 SQL 在数据量不大(几千用户以内)的情况下性能没问题。如果用户量大,建议在菜单权限表上建立索引,或者使用 Redis 缓存用户的权限列表。
4.3 步骤三:配置跨域与全局异常处理
很多前后端分离项目出问题,一大半都出在跨域配置上。我在项目里用 Spring 的CorsFilter统一处理跨域,有几个细节需要重点关注:
allowedOriginPatterns我使用通配符配置允许的来源域名,生产环境一定不要用*,否则任何网站都能调用你的接口。allowCredentials必须为 true,但要注意:allowCredentials为 true 时,allowedOrigin不能为*,必须指定具体域名或使用allowedOriginPatterns。allowedHeaders和allowedMethods一般放开即可,但exposedHeaders通常需要暴露Authorization。
@Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("http://localhost:8080"); config.addAllowedOriginPattern("https://yourdomain.com"); config.setAllowCredentials(true); config.addAllowedHeader("*"); config.addExposedHeader("Authorization"); config.addAllowedMethod("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); }全局异常处理这块,核心是把 Shiro 的异常转换为标准的 HTTP 状态码和 JSON 响应。我写了一个@RestControllerAdvice,针对UnauthorizedException返回 401,针对AuthorizationException返回 403,针对其他业务异常返回 400。这样前端只需要根据 HTTP 状态码就能判断接下来的处理逻辑,不需要解析异常消息。
4.4 步骤四:前后端联调与部署验证
联调阶段有个常见的现象:前端本地跑npm run dev,后端跑在本地 8080 端口,前端通过 Vite 代理把/api请求转发到后端。这个模式下跨域问题不存在(代理是同源的),但如果把前端打包后部署到 Nginx,然后通过域名访问,跨域问题才会真正暴露。我建议联调时直接使用生产模式的部署结构:前端打包部署到 Nginx,后端独立部署,这样所有问题可以在测试阶段暴露,而不是上线时才手忙脚乱。
这里也分享一个亲测有效的排错顺序:如果发现登录后请求依然 401,先打开浏览器开发者工具看请求头里Authorization是否带上;再看响应头里Access-Control-Allow-Origin是否存在,值是什么;最后看后端日志里是否有 Shiro 过滤链的拦截记录。按照这个顺序排查,90% 的问题都能快速定位。
5. 踩坑实录:前后端分离 + Shiro 的常见问题与解决方案
5.1 过滤器执行顺序的坑:跨域配置失效
这是一个非常容易踩的坑。Spring Boot 中,过滤器是有执行顺序的,CorsFilter和ShiroFilterFactoryBean的注册顺序直接决定了跨域请求能否正常到达后端接口。
我把项目跑起来后发现一个诡异现象:前端发起的 OPTIONS 预检请求一直报 401。排查了半天才意识到,Shiro 的过滤器链执行优先级高于CorsFilter,OPTIONS 请求直接被 Shiro 拦截了,根本没有走到跨域处理逻辑。
解决方案是在ShiroFilterFactoryBean的过滤链中,把 OPTIONS 请求显式放行:
filterChainDefinitionMap.put("/**", "jwt, anon"); // 错误写法 filterChainDefinitionMap.put("/**", "jwt"); // 错误,OPTIONS 会被 jwt 拦截 filterChainDefinitionMap.put("/api/**", "anon"); // 需要单独放行预检请求 filterChainDefinitionMap.put("/api/**", "jwt");正确的做法是,在过滤器内部先判断请求方法:
@Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { HttpServletRequest httpRequest = (HttpServletRequest) request; // 放行 OPTIONS 预检请求 if (HttpMethod.OPTIONS.matches(httpRequest.getMethod())) { return true; } // ... 其余逻辑 }5.2 跨域配置与 Shiro 过滤器优先级问题详解
上面提到的过滤器优先级问题,我想展开多说几句。Spring Boot 应用的过滤器执行顺序由 FilterRegistrationBean 的 order 控制,而 Shiro 的过滤器是通过ShiroFilterFactoryBean注册的,实际的过滤器实例是OncePerRequestFilter的子类。如果多个过滤器都实现了Filter接口,它们的执行顺序由注册顺序决定。
我随手做了一个小实验验证:在application.yml里配置的spring.mvc.servlet.load-on-startup属性和@Bean注册的过滤器,顺序并不完全可控。最稳妥的方案就是让 JWT 过滤器自己识别并放行 OPTIONS 请求。这也是很多开源项目采用的方式,而不是去依赖 Spring Boot 的过滤器排序。
如果前端使用了 axios 且自定义了请求头(Authorization),浏览器一定会先发送 OPTIONS 预检请求。后端如果不放行这个预检,真正的 POST / GET 请求根本到不了后端。这个坑直接导致了很多团队换成 Cookie 方案的原因,但其实只要放行预检请求就能解决。
5.3 权限缓存导致用户权限变更不立即生效
Shiro 默认会对AuthorizationInfo做缓存。用户在一个会话中首次访问某个接口后,Shiro 会把他的权限信息缓存到本地内存或 Redis。如果后台管理员修改了这个用户的角色或权限,第二次访问时 Shiro 还是会从缓存中读取旧权限,导致新权限不能立即生效。
我在项目里遇到过真实的案例:新入职的员工分配了角色,但前端刷新菜单立刻显示了新菜单,接口却依然返回 403。就是因为授权缓存没有清理。解决方式有两种:
第一种是简单粗暴的:配置授权缓存为不缓存。在 Realm 里设置setAuthorizationCachingEnabled(false)。缺点是每次请求都会查询数据库权限,性能会有一定损失。
第二种是保留缓存,但在管理员修改权限时手动清理缓存。Shiro 提供CacheManager接口,你可以注入 Redis 实现的缓存管理器,在权限变更的业务代码里调用cacheManager.getCache(AuthorizationInfo.class.getName()).remove(userId)。这种方式更优雅,但需要额外的缓存管理代码。
我的建议是:中小型项目直接关掉授权缓存,配合数据库查询索引,性能足够;大型项目用 Redis 缓存,并编写缓存清理工具类。
5.4 前端动态路由刷新页面后 404 的排查过程
这个问题比较隐蔽。我们的项目是动态菜单,用户登录后根据权限生成路由表。但刷新页面时,整个应用重新初始化,路由表需要重新从后端获取。如果这个过程时序处理不对,用户会看到「白屏」或「404」。
表现是这样的:用户按 F5 刷新当前页,路由守卫判断用户已登录(本地有 Token),但他还没获取到动态路由表,就直接访问了next()。这个路由在内存中还不存在,于是 Vue Router 找不到匹配项,跳到了 404 页面。
我的解决思路是在状态管理里加一个标记位isRoutesLoaded。路由守卫判断逻辑调整为:
- 已登录且路由表已加载 → 直接放行
- 已登录但路由表未加载 → 先调用后端接口获取菜单权限 → 动态添加路由表 → 标记加载完成 → 放行
- 未登录 → 跳转登录页
router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && !store.state.isRoutesLoaded) { // 先拉取用户信息与权限 const userInfo = await store.dispatch('fetchUserInfo') // 动态添加路由 const accessRoutes = generateRoutes(userInfo.permissions) router.addRoutes(accessRoutes) store.commit('SET_ROUTES_LOADED', true) next({ ...to, replace: true }) // 重新导航,确保新路由生效 } else { next() } })这里有个小细节:动态添加路由之后,当前to对象可能已经匹配到 404 路由,所以需要next({ ...to, replace: true })重新触发一次导航。这个技巧是踩过一次坑后得到的宝贵经验。
5.5 Token 过期与并发请求的 401 处理
还有一个体验层面的坑需要注意。当 Token 过期后,如果用户同时打开了多个标签页,或者某个页面同时发起多个请求,那么这几个请求会同时返回 401。如果前端每个 401 都做一次「清除 Token + 跳转登录页」操作,跳转动作会执行多次,用户体验很差,甚至可能因为竞态问题导致状态错乱。
我的处理方式是:在响应拦截器里用一个全局的isRedirecting标志位,只有第一次 401 触发跳转逻辑,其余 401 直接忽略。同时配合 Refresh Token 机制,在拦截器里先尝试刷新 Token,成功后重放原请求。这样用户无感知地完成了续签,只有刷新失败时才跳转登录页。
至于 Refresh Token 的存储,我选择放在内存中而不是localStorage,这样可以尽量避免 Token 被脚本读取窃取。页面刷新后需要重新登录,虽然体验略差,但安全性更高。如果项目对体验要求极高,可以调研 Refresh Token 的持久化方案,但要做好 XSS 攻击防护。
6. 额外的经验总结:这套方案的适用边界与扩展建议
Shiro + JWT 这套组合并不是万能的,我在多个项目里验证后,给它划了一条清晰的适用边界:适合中小型后台管理系统、内部工具平台、权限模型为 RBAC 的系统。如果项目规模很大,涉及 OAuth2 协议、OIDC、多租户隔离等复杂场景,那么 Spring Security + OAuth2 生态会更合适。但这并不代表 Shiro 方案低人一等,它在轻量、高效、易用上的优势是 Spring Security 难以比拟的。
关于扩展性,我还想提三个方向:
第一,集成 Redis 做 Token 黑白名单。JWT 本身是无状态的,但有时候我们需要强制让某个 Token 失效(比如用户改密码、管理员封禁账号)。做法是把 Token 的jti(唯一 ID)存入 Redis 黑名单,过滤器解析 Token 后先检查黑名单。这个改造不算复杂,却能让无状态认证拥有有状态控制的能力。
第二,多端登录控制。默认的 JWT 是无状态且并发的,意味着同一账号可以无限多地登录。如果要限制同一账号只能在一个设备上登录,可以在登录时给用户生成唯一的jti并存入 Redis,每次请求校验 Redis 中的jti是否与当前 Token 一致,不一致就判定为被顶下线。
第三,Shiro 框架本身也在发展。如果你的项目可以接受更高的学习成本,Shiro 3.x 的响应式风格和安全注解模型值得关注。但就目前而言,1.13 版本的生命力依然很强,社区资料丰富,遇到问题基本都能搜到解决方案。
最后说说我的个人体会。前后端分离的权限认证,表面上是一个技术选型问题,本质上是对状态管理的理解和取舍。Shiro 的灵活之处在于它把认证和授权抽象成非常清晰的接口,你可以选择 Session、可以选 JWT,甚至可以混合使用。只要理解了 Realm、Filter、Token 这三者的关系,遇到任何认证需求都能以最小的成本扩展。
做这个项目的过程中,我最大的收获不是把代码跑通了,而是建立了一套「按需求选择合适的认证架构」的判断方法。以后再有人问前后端分离怎么搞认证权限,我会直接把这篇文章和代码仓库丢给他,让他少踩几个我踩过的坑。