1. 项目概述与问题引入最近在整合一个Spring Boot Vue的前后端分离项目后端集成了Apache Shiro来做权限控制前端用Vue Router做路由守卫。项目跑起来后登录、菜单渲染都正常但只要一调用需要特定权限的接口前端就会弹出一个让人头疼的提示“没有认证Authentication”。控制台里Shiro的日志也明确告诉你Subject没有通过认证。这个问题太典型了几乎每个做前后端分离权限管理的新手都会踩这个坑。表面上看你登录成功了Session也创建了但为什么Shiro就是不认你呢这背后涉及的不是简单的配置错误而是前后端分离架构下传统的Session-Cookie认证模式失效后整个认证与授权链条的断裂与重构。今天我就以一个踩过无数坑的过来人身份把这个问题从根上掰扯清楚并提供一套从原理到实操再到深度排查的完整解决方案。2. 核心问题根源会话Session的跨域断链要解决问题必须先理解问题。在传统的单体应用或服务端渲染项目中用户登录后服务端会创建一个Session并将一个名为JSESSIONID的Cookie自动种在浏览器里。此后浏览器发出的每一个请求都会自动携带这个Cookie服务端的Shiro过滤器比如FormAuthenticationFilter就能从请求中提取到Session ID从而找到对应的Subject完成认证状态的恢复。这一切都是浏览器和Web容器如Tomcat自动完成的开发者几乎无感。但在前后端分离架构下这个链条断了。我们的Vue应用通常运行在http://localhost:8080而Spring Boot后端API运行在http://localhost:8081或另一个端口。浏览器出于安全考虑的同源策略默认不会跨域发送Cookie。即使你通过CORS配置允许了跨域请求和凭证withCredentials: true传统的基于Servlet容器的Session管理在分布式、无状态化的趋势下也显得力不从心。更常见的情况是前端使用Axios等库发送请求时根本没有携带任何能标识用户身份的凭证导致Shiro过滤器每次接收到的都是一个“全新的、未认证的请求”。所以“没有认证”的提示本质是认证令牌Token或会话标识Session ID在HTTP请求中丢失导致Shiro无法关联到已登录的用户主体。我们的核心任务就是在无状态的HTTP协议上重新建立一套可靠的、跨域的身份传递机制。2.1 技术选型Token-Based Authentication解决跨域认证主流方案是Token令牌机制。其核心流程是用户使用用户名密码登录。服务端验证成功后生成一个代表用户身份和权限的Token如JWT返回给前端。前端将此Token存储起来常用localStorage或sessionStorage。前端在后续所有需要认证的API请求中手动将这个Token添加到HTTP请求头中通常是Authorization头。服务端Shiro过滤器拦截请求从Authorization头中取出Token进行校验并重建Security Subject。相较于SessionToken方案是无状态的服务端不需要保存会话信息天然适合分布式和前后端分离场景。JWTJSON Web Token是一种流行的自包含Token标准它将用户信息和签名打包在一起但请注意Shiro本身不直接解析JWT我们需要进行适配。3. 解决方案一自定义Filter接管Shiro认证Shiro的安全控制依赖于过滤器链。默认的FormAuthenticationFilter是为表单登录和Session设计的。我们需要创建一个自定义的Filter将其插入到Shiro过滤器链的最前端专门负责从HTTP请求头中提取Token并进行认证。3.1 创建JWT工具类首先我们需要一个生成和验证JWT的工具。这里以jjwt库为例。import io.jsonwebtoken.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; Component public class JwtUtil { // 签名密钥应从配置文件中读取且足够复杂 Value(${jwt.secret:yourSecretKeyHere}) private String secret; // Token有效期毫秒 Value(${jwt.expire:3600000}) private long expire; /** * 生成JWT Token * param username 用户名 * return Token字符串 */ public String generateToken(String username) { Date now new Date(); Date expireDate new Date(now.getTime() expire); return Jwts.builder() .setHeaderParam(typ, JWT) .setSubject(username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } /** * 从Token中解析用户名 * param token JWT Token * return 用户名 */ public String getUsernameFromToken(String token) { Claims claims Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); return claims.getSubject(); } /** * 验证Token是否有效 * param token Token字符串 * return 是否有效 */ public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { // 日志记录异常生产环境应区分处理过期、签名错误等 return false; } } }注意secret密钥必须足够复杂且妥善保管绝不能硬编码在代码中提交到版本库。expire时间根据业务安全要求设定不宜过长。3.2 创建自定义Authentication Token和RealmShiro的认证需要AuthenticationToken。默认的UsernamePasswordToken不适合JWT我们需要自定义一个。public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String token) { this.token token; } Override public Object getPrincipal() { return token; } // 这里返回token本身 Override public Object getCredentials() { return token; } // 凭证也是token }接着创建一个Realm来验证这个JwtToken。public class JwtRealm extends AuthorizingRealm { Autowired private JwtUtil jwtUtil; Autowired private UserService userService; // 你的用户服务用于查询角色权限 // 告诉Shiro这个Realm支持哪种Token Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } /** * 认证逻辑验证JWT的有效性 */ Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { String jwtToken (String) authenticationToken.getCredentials(); if (jwtToken null || !jwtUtil.validateToken(jwtToken)) { throw new AuthenticationException(Token无效或已过期); } String username jwtUtil.getUsernameFromToken(jwtToken); // 这里可以添加更细粒度的检查如检查用户是否被禁用 return new SimpleAuthenticationInfo(username, jwtToken, getName()); } /** * 授权逻辑查询用户的角色和权限 * 该方法会在需要权限校验时被调用 */ Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); // 从数据库或缓存查询用户角色和权限 SetString roles userService.getUserRoles(username); SetString permissions userService.getUserPermissions(username); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.setRoles(roles); info.setStringPermissions(permissions); return info; } }3.3 创建核心JWT认证过滤器这是最关键的一步。这个过滤器将拦截所有请求尝试提取并验证JWT。public class JwtFilter extends AuthenticatingFilter { Autowired private JwtUtil jwtUtil; // 创建自定义的JwtToken Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest (HttpServletRequest) request; // 从请求头中获取Token标准格式是 Authorization: Bearer token String jwt getRequestToken(httpRequest); if (StringUtils.isBlank(jwt)) { return null; // 返回null表示该请求不需要本过滤器处理 } return new JwtToken(jwt); } // 尝试执行认证 Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { // 尝试执行登录认证 try { executeLogin(request, response); } catch (Exception e) { // 认证失败统一处理为未认证/未授权 onLoginFail(response); return false; // 拒绝访问 } return true; // 认证成功允许继续访问后续过滤器链 } // 从请求头提取Token private String getRequestToken(HttpServletRequest request) { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { return authHeader.substring(7); // 去掉Bearer 前缀 } return null; } // 认证失败时的处理 private void onLoginFail(ServletResponse response) throws IOException { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setCharacterEncoding(UTF-8); httpResponse.setContentType(application/json;charsetutf-8); // 返回一个结构化的错误信息方便前端处理 MapString, Object result new HashMap(); result.put(code, 401); result.put(msg, 未认证或Token已失效); result.put(data, null); String json new ObjectMapper().writeValueAsString(result); httpResponse.getWriter().print(json); } // 重写onLoginFailure避免重定向前后端分离不需要 Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { onLoginFail(response); return false; } }3.4 配置Shiro注入自定义组件最后在Shiro的配置类中将我们定义的JwtRealm和JwtFilter组装起来并配置好过滤器链。Configuration public class ShiroConfig { Bean public JwtRealm jwtRealm() { JwtRealm realm new JwtRealm(); // 可以设置凭证匹配器但JWT验证已在Realm内部完成这里可以不用 return realm; } Bean public DefaultWebSecurityManager securityManager(JwtRealm jwtRealm) { DefaultWebSecurityManager securityManager new DefaultWebSecurityManager(); securityManager.setRealm(jwtRealm); // 关闭Shiro自带的Session管理因为我们用无状态Token DefaultSessionManager sessionManager new DefaultSessionManager(); sessionManager.setSessionValidationSchedulerEnabled(false); securityManager.setSessionManager(sessionManager); return securityManager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager, JwtFilter jwtFilter) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 设置自定义过滤器 MapString, Filter filters new LinkedHashMap(); filters.put(jwt, jwtFilter); factoryBean.setFilters(filters); // 配置拦截规则 MapString, String filterChainDefinitionMap new LinkedHashMap(); // 公开接口不需要认证如登录、注册、Swagger文档 filterChainDefinitionMap.put(/api/auth/login, anon); filterChainDefinitionMap.put(/api/auth/register, anon); filterChainDefinitionMap.put(/swagger/**, anon); filterChainDefinitionMap.put(/v2/api-docs, anon); filterChainDefinitionMap.put(/webjars/**, anon); // 所有其他API接口都需要经过JWT过滤器 filterChainDefinitionMap.put(/api/**, jwt); // 注意顺序/**要放在最后 filterChainDefinitionMap.put(/**, anon); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } Bean public JwtFilter jwtFilter() { return new JwtFilter(); } }4. 解决方案二前端Vue的配合改造后端改造完成后前端必须相应地进行调整核心是登录后保存Token并在每次请求时携带它。4.1 登录逻辑改造在Vue的登录方法中成功登录后将后端返回的Token存储起来。// 在登录API调用成功后 import axios from axios; import { Message } from element-ui; // 示例UI库 export default { methods: { async handleLogin() { try { const response await axios.post(/api/auth/login, this.loginForm); if (response.data.code 200) { const token response.data.data.token; // 假设后端返回的Token字段是token // 存储到localStorage或Vuex中 localStorage.setItem(access_token, token); // 将Token设置到axios的默认请求头 axios.defaults.headers.common[Authorization] Bearer ${token}; Message.success(登录成功); this.$router.push(/dashboard); } else { Message.error(response.data.msg); } } catch (error) { Message.error(登录失败); console.error(error); } } } }4.2 配置Axios请求拦截器为了确保每个请求都自动携带Token最好在全局配置一个请求拦截器。// 在main.js或专门的request.js中 import axios from axios; import router from ./router; // 创建axios实例 const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 你的后端API地址 timeout: 10000 }); // 请求拦截器 service.interceptors.request.use( config { // 在发送请求之前做些什么 const token localStorage.getItem(access_token); if (token) { config.headers[Authorization] Bearer ${token}; } return config; }, error { // 对请求错误做些什么 console.error(Request Error:, error); return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( response { // 对响应数据做点什么 const res response.data; // 假设你的后端统一返回格式为 { code, msg, data } if (res.code 200) { return res.data; // 直接返回业务数据 } else if (res.code 401) { // Token过期或未认证 Message.error(登录已过期请重新登录); localStorage.removeItem(access_token); router.push(/login); return Promise.reject(new Error(未认证)); } else { // 其他业务错误 Message.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg || Error)); } }, error { // 对响应错误做点什么HTTP状态码非2xx console.error(Response Error:, error); if (error.response error.response.status 401) { Message.error(身份验证失败); localStorage.removeItem(access_token); router.push(/login); } else if (error.response error.response.status 403) { Message.error(权限不足); } else { Message.error(网络错误或服务器异常); } return Promise.reject(error); } ); export default service;4.3 路由守卫配置使用Vue Router的导航守卫在页面跳转前检查认证状态。// 在router/index.js中 import Vue from vue; import Router from vue-router; import store from /store; // 如果使用Vuex管理状态 Vue.use(Router); const router new Router({ routes: [...] }); // 全局前置守卫 router.beforeEach((to, from, next) { // 判断目标路由是否需要认证 if (to.matched.some(record record.meta.requiresAuth)) { const token localStorage.getItem(access_token); if (!token) { // 未登录跳转到登录页并携带目标路径方便登录后回跳 next({ path: /login, query: { redirect: to.fullPath } }); } else { // 已登录正常放行 next(); } } else { // 不需要认证的路由直接放行 next(); } }); export default router;在路由定义中可以为需要认证的路由添加元信息{ path: /dashboard, component: Dashboard, meta: { requiresAuth: true } }5. 深度排查与常见问题实录即使按照上述步骤配置你可能还是会遇到一些诡异的问题。下面是我在实际项目中踩过的坑和解决方案。5.1 问题一CORS跨域资源共享配置缺失或错误这是导致请求头包括Authorization头无法被后端接收的最常见原因。Spring Boot后端必须正确配置CORS。错误示例不完整配置Configuration public class CorsConfig { Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:8080); // 只设置了源缺少关键配置 } }; } }正确配置Configuration public class CorsConfig { Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) // 映射所有路径 .allowedOriginPatterns(*) // 允许所有源生产环境应指定具体域名 .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) // 允许的HTTP方法 .allowedHeaders(*) // 允许所有请求头这是关键否则Authorization头可能被拦截 .exposedHeaders(Authorization) // 允许前端访问的响应头可选 .allowCredentials(true) // 允许携带凭证如Cookie如果使用Cookie则必须为true .maxAge(3600); // 预检请求缓存时间秒 } }; } }实操心得allowedHeaders(*)这一行至关重要。浏览器在发送跨域请求前会先发送一个OPTIONS方法的预检请求Preflight Request询问服务器是否允许实际请求的头部和方法。如果服务器没有在Access-Control-Allow-Headers响应头中包含Authorization浏览器就会阻止实际请求的发送你的Token根本到不了Shiro过滤器。另外如果你的前端请求配置了withCredentials: true例如使用了Cookie那么allowCredentials(true)也必须设置且allowedOrigins不能使用通配符*必须指定明确的域名。5.2 问题二Shiro过滤器链顺序错误Shiro的过滤器链是有顺序的。如果你的自定义JwtFilter被放在了anon匿名过滤器之后或者被其他过滤器如logout意外拦截就会失效。排查方法在ShiroConfig中打印过滤器链定义检查/api/**路径是否确实映射到了jwt过滤器。在JwtFilter的onAccessDenied方法开始处打日志看请求是否进入了这个过滤器。确保你的自定义过滤器在ShiroFilterFactoryBean的filtersMap中被正确命名jwt并在filterChainDefinitionMap中被引用。一个典型的顺序陷阱如果你配置了logout过滤器并且它的路径模式如/logout被/**匹配了而/**又被设置为anon那么退出登录的请求可能根本不会经过认证逻辑。建议将/logout路径也交给jwt过滤器处理在过滤器中判断如果是退出请求则执行清理Token的逻辑。5.3 问题三Token过期、刷新与并发处理JWT Token有过期时间。过期后前端收到的响应码是401。此时需要引导用户重新登录或者实现更优雅的Token刷新机制。刷新Token方案登录时后端除了返回access_token短期有效如2小时还返回一个refresh_token长期有效如7天。refresh_token单独存储不用于常规API认证。当access_token过期导致API请求返回401时前端自动发起一个请求到专用的刷新接口如/api/auth/refresh携带refresh_token。后端验证refresh_token有效后颁发新的access_token和refresh_token。前端用新的access_token重试刚才失败的请求。这个逻辑可以在Axios的响应拦截器中实现但要注意防止多个并发请求同时触发刷新导致的“刷新风暴”。通常需要一个锁机制让刷新只执行一次。5.4 问题四Shiro的Subject线程绑定问题Shiro的Subject是与当前线程ThreadLocal绑定的。在Web应用中这通常由Shiro的过滤器管理。但在一些异步场景如使用Async注解、或消息队列消费者中新线程里可能获取不到认证信息。解决方案如果需要在异步线程中使用Shiro的权限检查你需要在开启新线程前将当前Subject的凭证如Token传递过去并在新线程中手动创建Subject并绑定。// 在主线程中 String currentToken (String) SecurityUtils.getSubject().getPrincipal(); // 在异步任务中 new Thread(() - { // 手动创建Subject并绑定到当前线程 Subject subject new Subject.Builder().buildSubject(); ThreadContext.bind(subject); // 使用传递过来的token进行认证这里需要模拟登录通常不推荐直接操作 // 更好的做法是在异步任务中调用一个服务方法该方法内部使用Token进行业务逻辑而非权限校验 }).start();注意事项在微服务或分布式系统中这种线程绑定的方式会非常棘手。更常见的做法是在异步任务或跨服务调用时将用户身份信息如userId作为参数显式传递而不是依赖线程上下文。权限校验的边界需要重新设计可能前置到API网关层。5.5 问题五静态资源被Shiro拦截如果你的Spring Boot项目同时提供前端静态资源比如把Vue打包后的dist目录放在resources/static/下并且Shiro的拦截规则配置了/**那么对index.html、css、js文件的请求也会被要求认证这显然不对。解决方案在Shiro的过滤器链中为静态资源路径配置anon匿名访问。filterChainDefinitionMap.put(/favicon.ico, anon); filterChainDefinitionMap.put(/static/**, anon); filterChainDefinitionMap.put(/index.html, anon); // 或者更宽泛一些但要注意安全 filterChainDefinitionMap.put(/*.html, anon); filterChainDefinitionMap.put(/*.js, anon); filterChainDefinitionMap.put(/*.css, anon); filterChainDefinitionMap.put(/*.png, anon);建议将前后端完全分离部署前端使用Nginx独立服务后端只提供纯API这样可以彻底避免此类问题也符合关注点分离的原则。6. 进阶思考无状态架构下的会话管理采用JWTShiro的方案后我们的系统变成了“无状态”的吗并不完全是。Shiro的Subject和授权信息从Realm的doGetAuthorizationInfo方法查询到的角色权限在默认情况下每次请求都会重新查询数据库。对于权限不常变的系统这会造成不必要的数据库压力。优化方案缓存授权信息Shiro支持缓存。我们可以为JwtRealm配置缓存管理器将用户的授权信息缓存起来。引入缓存依赖如Ehcache或Redis。在Shiro配置中设置缓存管理器。在JwtRealm中启用缓存。Bean public CacheManager cacheManager() { // 使用Ehcache return new MemoryConstrainedCacheManager(); // 或者使用Redis需引入shiro-redis等集成包 // RedisCacheManager cacheManager new RedisCacheManager(redisManager); // return cacheManager; } Bean public JwtRealm jwtRealm(CacheManager cacheManager) { JwtRealm realm new JwtRealm(); realm.setCacheManager(cacheManager); realm.setCachingEnabled(true); realm.setAuthenticationCachingEnabled(false); // 认证信息不建议缓存因为JWT每次都要验证 realm.setAuthorizationCachingEnabled(true); // 授权信息可以缓存 realm.setAuthorizationCacheName(authorizationCache); return realm; }缓存带来的新问题当用户的权限在后台发生变更时如何实时失效缓存这就需要引入缓存更新机制例如在修改用户角色的服务方法中主动清除对应用户的授权缓存。使用Redis时可以通过发布订阅消息来通知所有服务实例清除缓存。解决Spring Boot Vue Shiro前后端分离项目的“没有认证”问题是一个系统工程远不止在请求头里加个Token那么简单。它要求开发者深刻理解HTTP无状态协议、跨域安全策略、Shiro的过滤器链与认证授权原理以及前端路由与状态管理。从最基础的CORS配置到自定义Filter和Realm再到前端的Axios拦截与路由守卫每一步的疏忽都可能导致整个链条失效。我个人的经验是遇到这类问题一定要用浏览器的开发者工具从网络请求开始逐层排查请求头带了Token吗响应头有CORS错误吗请求进入后端Filter了吗Filter里成功解析Token了吗Subject绑定成功了吗只有像侦探一样追踪整个数据流才能精准定位问题所在。
Spring Boot + Vue前后端分离项目Shiro认证失效的完整解决方案
1. 项目概述与问题引入最近在整合一个Spring Boot Vue的前后端分离项目后端集成了Apache Shiro来做权限控制前端用Vue Router做路由守卫。项目跑起来后登录、菜单渲染都正常但只要一调用需要特定权限的接口前端就会弹出一个让人头疼的提示“没有认证Authentication”。控制台里Shiro的日志也明确告诉你Subject没有通过认证。这个问题太典型了几乎每个做前后端分离权限管理的新手都会踩这个坑。表面上看你登录成功了Session也创建了但为什么Shiro就是不认你呢这背后涉及的不是简单的配置错误而是前后端分离架构下传统的Session-Cookie认证模式失效后整个认证与授权链条的断裂与重构。今天我就以一个踩过无数坑的过来人身份把这个问题从根上掰扯清楚并提供一套从原理到实操再到深度排查的完整解决方案。2. 核心问题根源会话Session的跨域断链要解决问题必须先理解问题。在传统的单体应用或服务端渲染项目中用户登录后服务端会创建一个Session并将一个名为JSESSIONID的Cookie自动种在浏览器里。此后浏览器发出的每一个请求都会自动携带这个Cookie服务端的Shiro过滤器比如FormAuthenticationFilter就能从请求中提取到Session ID从而找到对应的Subject完成认证状态的恢复。这一切都是浏览器和Web容器如Tomcat自动完成的开发者几乎无感。但在前后端分离架构下这个链条断了。我们的Vue应用通常运行在http://localhost:8080而Spring Boot后端API运行在http://localhost:8081或另一个端口。浏览器出于安全考虑的同源策略默认不会跨域发送Cookie。即使你通过CORS配置允许了跨域请求和凭证withCredentials: true传统的基于Servlet容器的Session管理在分布式、无状态化的趋势下也显得力不从心。更常见的情况是前端使用Axios等库发送请求时根本没有携带任何能标识用户身份的凭证导致Shiro过滤器每次接收到的都是一个“全新的、未认证的请求”。所以“没有认证”的提示本质是认证令牌Token或会话标识Session ID在HTTP请求中丢失导致Shiro无法关联到已登录的用户主体。我们的核心任务就是在无状态的HTTP协议上重新建立一套可靠的、跨域的身份传递机制。2.1 技术选型Token-Based Authentication解决跨域认证主流方案是Token令牌机制。其核心流程是用户使用用户名密码登录。服务端验证成功后生成一个代表用户身份和权限的Token如JWT返回给前端。前端将此Token存储起来常用localStorage或sessionStorage。前端在后续所有需要认证的API请求中手动将这个Token添加到HTTP请求头中通常是Authorization头。服务端Shiro过滤器拦截请求从Authorization头中取出Token进行校验并重建Security Subject。相较于SessionToken方案是无状态的服务端不需要保存会话信息天然适合分布式和前后端分离场景。JWTJSON Web Token是一种流行的自包含Token标准它将用户信息和签名打包在一起但请注意Shiro本身不直接解析JWT我们需要进行适配。3. 解决方案一自定义Filter接管Shiro认证Shiro的安全控制依赖于过滤器链。默认的FormAuthenticationFilter是为表单登录和Session设计的。我们需要创建一个自定义的Filter将其插入到Shiro过滤器链的最前端专门负责从HTTP请求头中提取Token并进行认证。3.1 创建JWT工具类首先我们需要一个生成和验证JWT的工具。这里以jjwt库为例。import io.jsonwebtoken.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; Component public class JwtUtil { // 签名密钥应从配置文件中读取且足够复杂 Value(${jwt.secret:yourSecretKeyHere}) private String secret; // Token有效期毫秒 Value(${jwt.expire:3600000}) private long expire; /** * 生成JWT Token * param username 用户名 * return Token字符串 */ public String generateToken(String username) { Date now new Date(); Date expireDate new Date(now.getTime() expire); return Jwts.builder() .setHeaderParam(typ, JWT) .setSubject(username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } /** * 从Token中解析用户名 * param token JWT Token * return 用户名 */ public String getUsernameFromToken(String token) { Claims claims Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); return claims.getSubject(); } /** * 验证Token是否有效 * param token Token字符串 * return 是否有效 */ public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { // 日志记录异常生产环境应区分处理过期、签名错误等 return false; } } }注意secret密钥必须足够复杂且妥善保管绝不能硬编码在代码中提交到版本库。expire时间根据业务安全要求设定不宜过长。3.2 创建自定义Authentication Token和RealmShiro的认证需要AuthenticationToken。默认的UsernamePasswordToken不适合JWT我们需要自定义一个。public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String token) { this.token token; } Override public Object getPrincipal() { return token; } // 这里返回token本身 Override public Object getCredentials() { return token; } // 凭证也是token }接着创建一个Realm来验证这个JwtToken。public class JwtRealm extends AuthorizingRealm { Autowired private JwtUtil jwtUtil; Autowired private UserService userService; // 你的用户服务用于查询角色权限 // 告诉Shiro这个Realm支持哪种Token Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } /** * 认证逻辑验证JWT的有效性 */ Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { String jwtToken (String) authenticationToken.getCredentials(); if (jwtToken null || !jwtUtil.validateToken(jwtToken)) { throw new AuthenticationException(Token无效或已过期); } String username jwtUtil.getUsernameFromToken(jwtToken); // 这里可以添加更细粒度的检查如检查用户是否被禁用 return new SimpleAuthenticationInfo(username, jwtToken, getName()); } /** * 授权逻辑查询用户的角色和权限 * 该方法会在需要权限校验时被调用 */ Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); // 从数据库或缓存查询用户角色和权限 SetString roles userService.getUserRoles(username); SetString permissions userService.getUserPermissions(username); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.setRoles(roles); info.setStringPermissions(permissions); return info; } }3.3 创建核心JWT认证过滤器这是最关键的一步。这个过滤器将拦截所有请求尝试提取并验证JWT。public class JwtFilter extends AuthenticatingFilter { Autowired private JwtUtil jwtUtil; // 创建自定义的JwtToken Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest (HttpServletRequest) request; // 从请求头中获取Token标准格式是 Authorization: Bearer token String jwt getRequestToken(httpRequest); if (StringUtils.isBlank(jwt)) { return null; // 返回null表示该请求不需要本过滤器处理 } return new JwtToken(jwt); } // 尝试执行认证 Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { // 尝试执行登录认证 try { executeLogin(request, response); } catch (Exception e) { // 认证失败统一处理为未认证/未授权 onLoginFail(response); return false; // 拒绝访问 } return true; // 认证成功允许继续访问后续过滤器链 } // 从请求头提取Token private String getRequestToken(HttpServletRequest request) { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { return authHeader.substring(7); // 去掉Bearer 前缀 } return null; } // 认证失败时的处理 private void onLoginFail(ServletResponse response) throws IOException { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setCharacterEncoding(UTF-8); httpResponse.setContentType(application/json;charsetutf-8); // 返回一个结构化的错误信息方便前端处理 MapString, Object result new HashMap(); result.put(code, 401); result.put(msg, 未认证或Token已失效); result.put(data, null); String json new ObjectMapper().writeValueAsString(result); httpResponse.getWriter().print(json); } // 重写onLoginFailure避免重定向前后端分离不需要 Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { onLoginFail(response); return false; } }3.4 配置Shiro注入自定义组件最后在Shiro的配置类中将我们定义的JwtRealm和JwtFilter组装起来并配置好过滤器链。Configuration public class ShiroConfig { Bean public JwtRealm jwtRealm() { JwtRealm realm new JwtRealm(); // 可以设置凭证匹配器但JWT验证已在Realm内部完成这里可以不用 return realm; } Bean public DefaultWebSecurityManager securityManager(JwtRealm jwtRealm) { DefaultWebSecurityManager securityManager new DefaultWebSecurityManager(); securityManager.setRealm(jwtRealm); // 关闭Shiro自带的Session管理因为我们用无状态Token DefaultSessionManager sessionManager new DefaultSessionManager(); sessionManager.setSessionValidationSchedulerEnabled(false); securityManager.setSessionManager(sessionManager); return securityManager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager, JwtFilter jwtFilter) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 设置自定义过滤器 MapString, Filter filters new LinkedHashMap(); filters.put(jwt, jwtFilter); factoryBean.setFilters(filters); // 配置拦截规则 MapString, String filterChainDefinitionMap new LinkedHashMap(); // 公开接口不需要认证如登录、注册、Swagger文档 filterChainDefinitionMap.put(/api/auth/login, anon); filterChainDefinitionMap.put(/api/auth/register, anon); filterChainDefinitionMap.put(/swagger/**, anon); filterChainDefinitionMap.put(/v2/api-docs, anon); filterChainDefinitionMap.put(/webjars/**, anon); // 所有其他API接口都需要经过JWT过滤器 filterChainDefinitionMap.put(/api/**, jwt); // 注意顺序/**要放在最后 filterChainDefinitionMap.put(/**, anon); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } Bean public JwtFilter jwtFilter() { return new JwtFilter(); } }4. 解决方案二前端Vue的配合改造后端改造完成后前端必须相应地进行调整核心是登录后保存Token并在每次请求时携带它。4.1 登录逻辑改造在Vue的登录方法中成功登录后将后端返回的Token存储起来。// 在登录API调用成功后 import axios from axios; import { Message } from element-ui; // 示例UI库 export default { methods: { async handleLogin() { try { const response await axios.post(/api/auth/login, this.loginForm); if (response.data.code 200) { const token response.data.data.token; // 假设后端返回的Token字段是token // 存储到localStorage或Vuex中 localStorage.setItem(access_token, token); // 将Token设置到axios的默认请求头 axios.defaults.headers.common[Authorization] Bearer ${token}; Message.success(登录成功); this.$router.push(/dashboard); } else { Message.error(response.data.msg); } } catch (error) { Message.error(登录失败); console.error(error); } } } }4.2 配置Axios请求拦截器为了确保每个请求都自动携带Token最好在全局配置一个请求拦截器。// 在main.js或专门的request.js中 import axios from axios; import router from ./router; // 创建axios实例 const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 你的后端API地址 timeout: 10000 }); // 请求拦截器 service.interceptors.request.use( config { // 在发送请求之前做些什么 const token localStorage.getItem(access_token); if (token) { config.headers[Authorization] Bearer ${token}; } return config; }, error { // 对请求错误做些什么 console.error(Request Error:, error); return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( response { // 对响应数据做点什么 const res response.data; // 假设你的后端统一返回格式为 { code, msg, data } if (res.code 200) { return res.data; // 直接返回业务数据 } else if (res.code 401) { // Token过期或未认证 Message.error(登录已过期请重新登录); localStorage.removeItem(access_token); router.push(/login); return Promise.reject(new Error(未认证)); } else { // 其他业务错误 Message.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg || Error)); } }, error { // 对响应错误做点什么HTTP状态码非2xx console.error(Response Error:, error); if (error.response error.response.status 401) { Message.error(身份验证失败); localStorage.removeItem(access_token); router.push(/login); } else if (error.response error.response.status 403) { Message.error(权限不足); } else { Message.error(网络错误或服务器异常); } return Promise.reject(error); } ); export default service;4.3 路由守卫配置使用Vue Router的导航守卫在页面跳转前检查认证状态。// 在router/index.js中 import Vue from vue; import Router from vue-router; import store from /store; // 如果使用Vuex管理状态 Vue.use(Router); const router new Router({ routes: [...] }); // 全局前置守卫 router.beforeEach((to, from, next) { // 判断目标路由是否需要认证 if (to.matched.some(record record.meta.requiresAuth)) { const token localStorage.getItem(access_token); if (!token) { // 未登录跳转到登录页并携带目标路径方便登录后回跳 next({ path: /login, query: { redirect: to.fullPath } }); } else { // 已登录正常放行 next(); } } else { // 不需要认证的路由直接放行 next(); } }); export default router;在路由定义中可以为需要认证的路由添加元信息{ path: /dashboard, component: Dashboard, meta: { requiresAuth: true } }5. 深度排查与常见问题实录即使按照上述步骤配置你可能还是会遇到一些诡异的问题。下面是我在实际项目中踩过的坑和解决方案。5.1 问题一CORS跨域资源共享配置缺失或错误这是导致请求头包括Authorization头无法被后端接收的最常见原因。Spring Boot后端必须正确配置CORS。错误示例不完整配置Configuration public class CorsConfig { Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:8080); // 只设置了源缺少关键配置 } }; } }正确配置Configuration public class CorsConfig { Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) // 映射所有路径 .allowedOriginPatterns(*) // 允许所有源生产环境应指定具体域名 .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) // 允许的HTTP方法 .allowedHeaders(*) // 允许所有请求头这是关键否则Authorization头可能被拦截 .exposedHeaders(Authorization) // 允许前端访问的响应头可选 .allowCredentials(true) // 允许携带凭证如Cookie如果使用Cookie则必须为true .maxAge(3600); // 预检请求缓存时间秒 } }; } }实操心得allowedHeaders(*)这一行至关重要。浏览器在发送跨域请求前会先发送一个OPTIONS方法的预检请求Preflight Request询问服务器是否允许实际请求的头部和方法。如果服务器没有在Access-Control-Allow-Headers响应头中包含Authorization浏览器就会阻止实际请求的发送你的Token根本到不了Shiro过滤器。另外如果你的前端请求配置了withCredentials: true例如使用了Cookie那么allowCredentials(true)也必须设置且allowedOrigins不能使用通配符*必须指定明确的域名。5.2 问题二Shiro过滤器链顺序错误Shiro的过滤器链是有顺序的。如果你的自定义JwtFilter被放在了anon匿名过滤器之后或者被其他过滤器如logout意外拦截就会失效。排查方法在ShiroConfig中打印过滤器链定义检查/api/**路径是否确实映射到了jwt过滤器。在JwtFilter的onAccessDenied方法开始处打日志看请求是否进入了这个过滤器。确保你的自定义过滤器在ShiroFilterFactoryBean的filtersMap中被正确命名jwt并在filterChainDefinitionMap中被引用。一个典型的顺序陷阱如果你配置了logout过滤器并且它的路径模式如/logout被/**匹配了而/**又被设置为anon那么退出登录的请求可能根本不会经过认证逻辑。建议将/logout路径也交给jwt过滤器处理在过滤器中判断如果是退出请求则执行清理Token的逻辑。5.3 问题三Token过期、刷新与并发处理JWT Token有过期时间。过期后前端收到的响应码是401。此时需要引导用户重新登录或者实现更优雅的Token刷新机制。刷新Token方案登录时后端除了返回access_token短期有效如2小时还返回一个refresh_token长期有效如7天。refresh_token单独存储不用于常规API认证。当access_token过期导致API请求返回401时前端自动发起一个请求到专用的刷新接口如/api/auth/refresh携带refresh_token。后端验证refresh_token有效后颁发新的access_token和refresh_token。前端用新的access_token重试刚才失败的请求。这个逻辑可以在Axios的响应拦截器中实现但要注意防止多个并发请求同时触发刷新导致的“刷新风暴”。通常需要一个锁机制让刷新只执行一次。5.4 问题四Shiro的Subject线程绑定问题Shiro的Subject是与当前线程ThreadLocal绑定的。在Web应用中这通常由Shiro的过滤器管理。但在一些异步场景如使用Async注解、或消息队列消费者中新线程里可能获取不到认证信息。解决方案如果需要在异步线程中使用Shiro的权限检查你需要在开启新线程前将当前Subject的凭证如Token传递过去并在新线程中手动创建Subject并绑定。// 在主线程中 String currentToken (String) SecurityUtils.getSubject().getPrincipal(); // 在异步任务中 new Thread(() - { // 手动创建Subject并绑定到当前线程 Subject subject new Subject.Builder().buildSubject(); ThreadContext.bind(subject); // 使用传递过来的token进行认证这里需要模拟登录通常不推荐直接操作 // 更好的做法是在异步任务中调用一个服务方法该方法内部使用Token进行业务逻辑而非权限校验 }).start();注意事项在微服务或分布式系统中这种线程绑定的方式会非常棘手。更常见的做法是在异步任务或跨服务调用时将用户身份信息如userId作为参数显式传递而不是依赖线程上下文。权限校验的边界需要重新设计可能前置到API网关层。5.5 问题五静态资源被Shiro拦截如果你的Spring Boot项目同时提供前端静态资源比如把Vue打包后的dist目录放在resources/static/下并且Shiro的拦截规则配置了/**那么对index.html、css、js文件的请求也会被要求认证这显然不对。解决方案在Shiro的过滤器链中为静态资源路径配置anon匿名访问。filterChainDefinitionMap.put(/favicon.ico, anon); filterChainDefinitionMap.put(/static/**, anon); filterChainDefinitionMap.put(/index.html, anon); // 或者更宽泛一些但要注意安全 filterChainDefinitionMap.put(/*.html, anon); filterChainDefinitionMap.put(/*.js, anon); filterChainDefinitionMap.put(/*.css, anon); filterChainDefinitionMap.put(/*.png, anon);建议将前后端完全分离部署前端使用Nginx独立服务后端只提供纯API这样可以彻底避免此类问题也符合关注点分离的原则。6. 进阶思考无状态架构下的会话管理采用JWTShiro的方案后我们的系统变成了“无状态”的吗并不完全是。Shiro的Subject和授权信息从Realm的doGetAuthorizationInfo方法查询到的角色权限在默认情况下每次请求都会重新查询数据库。对于权限不常变的系统这会造成不必要的数据库压力。优化方案缓存授权信息Shiro支持缓存。我们可以为JwtRealm配置缓存管理器将用户的授权信息缓存起来。引入缓存依赖如Ehcache或Redis。在Shiro配置中设置缓存管理器。在JwtRealm中启用缓存。Bean public CacheManager cacheManager() { // 使用Ehcache return new MemoryConstrainedCacheManager(); // 或者使用Redis需引入shiro-redis等集成包 // RedisCacheManager cacheManager new RedisCacheManager(redisManager); // return cacheManager; } Bean public JwtRealm jwtRealm(CacheManager cacheManager) { JwtRealm realm new JwtRealm(); realm.setCacheManager(cacheManager); realm.setCachingEnabled(true); realm.setAuthenticationCachingEnabled(false); // 认证信息不建议缓存因为JWT每次都要验证 realm.setAuthorizationCachingEnabled(true); // 授权信息可以缓存 realm.setAuthorizationCacheName(authorizationCache); return realm; }缓存带来的新问题当用户的权限在后台发生变更时如何实时失效缓存这就需要引入缓存更新机制例如在修改用户角色的服务方法中主动清除对应用户的授权缓存。使用Redis时可以通过发布订阅消息来通知所有服务实例清除缓存。解决Spring Boot Vue Shiro前后端分离项目的“没有认证”问题是一个系统工程远不止在请求头里加个Token那么简单。它要求开发者深刻理解HTTP无状态协议、跨域安全策略、Shiro的过滤器链与认证授权原理以及前端路由与状态管理。从最基础的CORS配置到自定义Filter和Realm再到前端的Axios拦截与路由守卫每一步的疏忽都可能导致整个链条失效。我个人的经验是遇到这类问题一定要用浏览器的开发者工具从网络请求开始逐层排查请求头带了Token吗响应头有CORS错误吗请求进入后端Filter了吗Filter里成功解析Token了吗Subject绑定成功了吗只有像侦探一样追踪整个数据流才能精准定位问题所在。