SpringBoot+Vue前后端分离项目Shiro整合JWT认证实战与故障排查
1. 从一次真实的线上故障说起:为什么登录了却告诉我“没有认证”?
那天下午,我正在工位上摸鱼,突然钉钉群里炸开了锅。运营同事发来一串截图,附带一个愤怒的表情:“后台系统又崩了!我明明登录了,一点查询按钮就弹窗说‘没有认证’,数据全看不到了!” 紧接着,测试和产品也纷纷冒泡,表示复现了同样的问题。我心头一紧,这是我们刚上线不久的SpringBoot + Vue前后端分离项目,核心的权限框架用的正是Shiro。表面上看,用户成功登录,拿到了Token,但进行后续操作时,Shiro的拦截器却无情地抛出了UnauthenticatedException,前端Vue这边统一拦截后,弹出了那个令人头疼的“没有认证”提示。
这个问题非常典型,也极具迷惑性。它不像“密码错误”那样直接,而是处在一种“薛定谔的认证”状态——你说他没登录吧,他确实发起了带Token的请求;你说他登录了吧,Shiro又不认。这种问题在前后端分离、尤其是采用Token(如JWT)作为认证凭证的架构中尤为常见。其根源往往不在于Shiro本身有Bug,而在于我们对Shiro在无状态环境下的工作模式理解不透彻,或者在Token的“传递-校验-绑定”这个链条的某个环节上出现了纰漏。接下来,我就结合这次排查和修复的全过程,把里面涉及的核心技术点、各种可能的坑以及最终的解决方案,掰开揉碎了讲清楚。
2. 理解症结:Shiro在前后端分离架构下的“水土不服”
要解决问题,首先得理解问题是怎么来的。传统的Shiro设计是围绕Session的,它默认认为用户的认证信息(Subject)是绑定在服务器内存的Session里的。用户登录后,Shiro会将认证信息存入Session,浏览器后续请求通过Cookie中的JSESSIONID来找到这个Session,从而识别用户身份。这套机制在单体、前后端不分离的应用中运行良好。
但在我们这种SpringBoot + Vue的前后端分离项目中,情况变了:
- 无状态化:我们通常采用JWT等Token机制,追求无状态(Stateless),服务器端不保存Session。每次请求的认证信息都包含在Token里。
- 跨域请求:Vue应用运行在独立的端口(如
localhost:8080),而SpringBoot API运行在另一个端口(如localhost:8081)。Cookie在跨域时受到严格限制(需要配置CORS和credentials: ‘include’),我们通常也不依赖Cookie传Token。 - Token传递方式:Token通过HTTP请求头(通常是
Authorization: Bearer <token>)传递,而不是通过Cookie。
这时,如果我们只是简单地将Shiro集成进来,而不做任何定制,就会出现开头描述的问题:登录接口(比如/login)我们可能手动处理了,生成了Token返回给前端。但当前端拿着这个Token去访问另一个需要权限的接口(比如/api/user/list)时,Shiro内置的过滤器链(如authc)会拦截这个请求。它会按照默认逻辑去寻找Session里的认证信息,显然找不到,于是判定用户“未认证”,直接抛出异常。
所以,问题的本质是:我们需要教会Shiro,如何从我们自定义的地方(即HTTP请求头中的Token)来获取和验证用户的身份,而不是死守着Session不放。这需要我们深入Shiro的核心扩展点进行定制。
3. 核心改造一:打造无状态的Shiro过滤器与Realm
要让Shiro适应Token认证,我们需要改造两个核心组件:过滤器(Filter)和Realm。
3.1 创建JWT过滤器:替代默认的FormAuthenticationFilter
Shiro通过过滤器链来拦截请求并进行安全控制。默认的FormAuthenticationFilter是为表单登录设计的,会期望username和password参数。我们需要创建一个自定义的JWT过滤器。
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class JwtFilter extends BasicHttpAuthenticationFilter { /** * 判断请求是否带有Token * 我们约定Token放在Header的`Authorization`字段中,格式为`Bearer <token>` */ @Override protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { HttpServletRequest req = (HttpServletRequest) request; String authorization = req.getHeader("Authorization"); return authorization != null && authorization.startsWith("Bearer "); } /** * 执行登录认证 * 从Header中提取Token,并提交给Shiro进行登录 */ @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; String authorizationHeader = httpServletRequest.getHeader("Authorization"); // 提取JWT Token,去掉"Bearer "前缀 String token = authorizationHeader.substring(7); JwtToken jwtToken = new JwtToken(token); // 调用Subject的login方法,这会触发我们的自定义Realm进行认证 getSubject(request, response).login(jwtToken); // 如果上面login方法没有抛出异常,说明认证成功 return true; } /** * 决定是否允许访问 * 1. 如果请求头有Token,则尝试用Token登录(调用executeLogin) * 2. 如果请求头无Token,且当前资源需要认证(isAccessAllowed为false),则返回false,触发onAccessDenied * 3. 对于OPTIONS预检请求,直接放行(处理CORS) */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { // 处理CORS预检请求 if (isOptionsRequest(request)) { return true; } // 如果请求带有Token,尝试登录 if (isLoginAttempt(request, response)) { try { executeLogin(request, response); return true; // 登录成功,允许访问 } catch (Exception e) { // Token无效或过期,登录失败 // 这里不直接处理响应,由onAccessDenied统一处理 return false; } } // 没有Token,且资源需要认证,则不允许访问 return false; } /** * 当isAccessAllowed返回false时调用 * 我们在这里统一处理认证失败的情况,返回标准的JSON错误信息,而不是跳转页面。 */ @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setContentType("application/json;charset=utf-8"); httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401状态码 // 构建一个清晰的JSON错误响应 String jsonResponse = "{\"code\": 401, \"msg\": \"未经认证或Token已失效,请重新登录\", \"data\": null}"; httpResponse.getWriter().write(jsonResponse); return false; // 不再继续处理过滤器链 } private boolean isOptionsRequest(ServletRequest request) { HttpServletRequest req = (HttpServletRequest) request; return "OPTIONS".equalsIgnoreCase(req.getMethod()); } }关键点解析:
- 继承
BasicHttpAuthenticationFilter:这个基类比FormAuthenticationFilter更接近HTTP基础认证,更适合我们处理Header Token的场景。 isLoginAttempt:这是我们的“侦察兵”,判断当前请求是否携带了我们认可的Token格式。这里我们约定了Authorization: Bearer <token>的格式,这是JWT在OAuth 2.0和RESTful API中的事实标准。executeLogin:这是“登录执行者”。它从Header提取出Token字符串,然后包装成一个JwtToken对象(一个自定义的AuthenticationToken实现),最后调用Subject.login(token)。这一步是连接过滤器和Realm的桥梁,login方法会触发Realm的doGetAuthenticationInfo方法。isAccessAllowed:这是“访问裁决者”。它决定了当前请求是否被允许访问资源。我们的逻辑是:有Token就尝试登录,登录成功就放行;对于浏览器发起的CORS预检请求(OPTIONS方法),直接放行,这是前后端分离项目必须处理的。onAccessDenied:这是“失败处理者”。当认证失败(Token无效、过期或根本没有Token)时,我们不再像传统Web应用那样重定向到登录页,而是直接返回一个JSON格式的401错误。这符合RESTful API的规范,前端Vue的axios拦截器可以统一捕获这个状态码,然后跳转到登录页面。
3.2 创建自定义的JwtToken对象
这个对象很简单,就是用来包装我们从请求头中提取的Token字符串,以便传递给Realm。
import org.apache.shiro.authc.AuthenticationToken; public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String token) { this.token = token; } @Override public Object getPrincipal() { return token; // 通常,principal可以是token本身,或者从token中解析出的用户名 } @Override public Object getCredentials() { return token; // 在JWT场景下,credentials也是token本身 } public String getToken() { return token; } }3.3 改造自定义Realm:支持Token认证
Realm是Shiro的灵魂,负责具体的认证(你是谁?)和授权(你能做什么?)。我们需要让我们的Realm能够处理JwtToken。
import org.apache.shiro.authc.*; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.springframework.beans.factory.annotation.Autowired; public class UserRealm extends AuthorizingRealm { @Autowired private JwtUtil jwtUtil; // 一个自定义的JWT工具类,用于校验和解析Token /** * 关键!告诉Shiro,这个Realm支持处理哪种类型的Token */ @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } /** * 认证逻辑(登录) * 当Subject.login(JwtToken)被调用时,会执行这个方法 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { JwtToken jwtToken = (JwtToken) authenticationToken; String token = (String) jwtToken.getCredentials(); // 1. 校验Token的基本有效性(是否过期、签名是否正确) if (!jwtUtil.verifyToken(token)) { throw new ExpiredCredentialsException("Token无效或已过期"); } // 2. 从Token中解析出用户标识(例如用户名或用户ID) String username = jwtUtil.getUsernameFromToken(token); if (username == null) { throw new AuthenticationException("Token中无法解析出用户信息"); } // 3. (可选) 进一步业务校验,例如检查用户是否被禁用 // User user = userService.findByUsername(username); // if (user == null || user.getStatus() == 0) { // throw new LockedAccountException("账户已被禁用或不存在"); // } // 4. 构建AuthenticationInfo对象返回给Shiro // 参数:principal(身份), credentials(凭证), realmName(当前Realm名) // 注意:这里credentials传的是token,因为JWT自身就是凭证。Shiro会用它和传入的Token比较。 return new SimpleAuthenticationInfo(username, token, getName()); } /** * 授权逻辑(权限校验) * 当调用`@RequiresRoles`, `@RequiresPermissions`或`subject.isPermitted()`时触发 * 注意:由于每次请求都可能触发授权,这里的信息最好缓存起来,避免频繁解析Token或查库。 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 1. 获取当前用户的身份(我们在认证时存的username) String username = (String) principals.getPrimaryPrincipal(); // 2. 根据用户名,查询数据库,获取用户的角色和权限列表 // Set<String> roles = roleService.findRolesByUsername(username); // Set<String> permissions = permissionService.findPermissionsByUsername(username); // 3. 构建AuthorizationInfo对象 // SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); // authorizationInfo.setRoles(roles); // authorizationInfo.setStringPermissions(permissions); // return authorizationInfo; // 此处简化,直接返回一个空授权信息。实际项目需要实现完整的查询逻辑。 return null; } }关键点解析:
supports方法:这是第一个关键!必须重写此方法,声明本Realm只处理JwtToken类型。这样,当我们的JwtFilter调用login(JwtToken)时,Shiro才知道应该路由到这个Realm来执行认证。doGetAuthenticationInfo:这是认证的核心。它接收JwtToken,然后:- 使用
JwtUtil校验Token的签名和有效期。如果无效,抛出ExpiredCredentialsException等异常,这个异常会被Shiro捕获,最终导致isAccessAllowed返回false。 - 从有效的Token中解析出用户标识(如username)。
- (可选)进行额外的业务状态检查(如用户是否被锁定)。
- 返回一个
SimpleAuthenticationInfo对象。这里有个重要细节:我们把username作为principal(身份),把token字符串本身作为credentials(凭证)。在Shiro的默认逻辑里,它会比较这里返回的credentials和用户登录时传入的token的credentials是否一致。由于我们返回的和传入的都是同一个token字符串,所以这个比较总是成功。我们的核心校验逻辑其实是在jwtUtil.verifyToken(token)这一步完成的。
- 使用
doGetAuthorizationInfo:授权逻辑。它会在需要检查权限时被调用。这里有一个性能优化点:每次授权都去解析Token或查数据库是很耗时的。通常的做法是将用户的角色权限信息也编码到JWT的Payload里,或者在使用Redis等缓存,在认证成功后就将权限信息缓存起来,授权时直接从缓存获取。
4. 核心改造二:正确配置Shiro,注入自定义组件
改造了核心组件,还需要在Spring Boot的配置类中将它们组装起来。一个常见的错误就是过滤器配置不对,导致自定义的JwtFilter根本没生效。
import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.servlet.Filter; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; @Configuration public class ShiroConfig { // 1. 创建自定义Realm @Bean public UserRealm userRealm() { return new UserRealm(); } // 2. 创建SecurityManager,并设置Realm @Bean public DefaultWebSecurityManager securityManager(UserRealm userRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(userRealm); // 注意:因为我们用了Token,不需要SessionManager,可以禁用Session // DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); // sessionManager.setSessionValidationSchedulerEnabled(false); // securityManager.setSessionManager(sessionManager); return securityManager; } // 3. 创建ShiroFilterFactoryBean,这是配置拦截规则的核心 @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 3.1 注册自定义的JWT过滤器,并给它起个名字,比如`jwt` Map<String, Filter> filters = new HashMap<>(); filters.put("jwt", new JwtFilter()); // 关键!将我们写的JwtFilter实例放进去 factoryBean.setFilters(filters); // 3.2 配置拦截规则链 (注意使用有序的LinkedHashMap) Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); // 3.2.1 放行公开接口(登录、注册、Swagger文档等) filterChainDefinitionMap.put("/api/user/login", "anon"); // anon表示匿名访问 filterChainDefinitionMap.put("/api/user/register", "anon"); filterChainDefinitionMap.put("/swagger/**", "anon"); filterChainDefinitionMap.put("/v2/api-docs", "anon"); filterChainDefinitionMap.put("/webjars/**", "anon"); filterChainDefinitionMap.put("/doc.html", "anon"); // 3.2.2 放行所有OPTIONS请求(CORS预检) filterChainDefinitionMap.put("/**", "anon[options]"); // 需要Shiro版本支持,或在上面的JwtFilter中处理 // 3.2.3 最重要的部分:对所有需要认证的API路径,应用我们自定义的`jwt`过滤器 filterChainDefinitionMap.put("/api/**", "jwt"); // 所有`/api/`开头的请求都需要JWT认证 // 更细粒度的控制示例: // filterChainDefinitionMap.put("/api/admin/**", "jwt, roles[admin]"); // 需要JWT认证且角色为admin // filterChainDefinitionMap.put("/api/user/profile", "jwt, perms[user:view]"); // 需要JWT认证且拥有特定权限 // 3.2.4 默认策略:其他所有请求需要认证(根据项目需求调整) filterChainDefinitionMap.put("/**", "jwt"); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); // 3.3 设置登录和未授权跳转页(前后端分离项目通常不需要,因为我们在onAccessDenied中返回了JSON) // factoryBean.setLoginUrl("/api/user/unlogin"); // factoryBean.setUnauthorizedUrl("/api/user/unauth"); return factoryBean; } }配置陷阱与经验:
- 过滤器注册:
factoryBean.setFilters(filters)这一步至关重要。你必须把自定义的JwtFilter实例放到这个Map里,并赋予一个名字(如”jwt”),后续在规则链里才能通过这个名字引用它。 - 规则链顺序:
LinkedHashMap的顺序就是匹配顺序,从上到下,首次匹配成功即返回。所以要把最具体的、需要放行的规则(如/login)放在前面,把最通用的拦截规则(如/api/**)放在后面。如果把/**放在第一行,那所有请求都会被拦截,登录接口都进不来。 - CORS预检请求:浏览器在发送跨域复杂请求前,会先发一个
OPTIONS方法的预检请求。这个请求不能带自定义的Authorization头。如果我们的jwt过滤器拦截了它并因为找不到Token而拒绝,就会导致CORS失败。解决方案有两种:一是在JwtFilter的isAccessAllowed方法中判断如果是OPTIONS请求就直接放行(如上文代码所示);二是在规则链里单独放行OPTIONS方法(取决于Shiro版本是否支持anon[options]语法)。 - 禁用Session:在纯Token无状态应用中,可以考虑禁用Shiro的Session,避免创建无用的Session对象占用内存。可以通过注入自定义的
SessionManager并关闭会话验证调度器来实现。
5. 前端Vue的配合:如何正确地携带Token
后端改造完毕,前端如果Token传递不对,一切白搭。前端的工作主要集中在HTTP请求拦截器上。
// 以axios为例,在request拦截器中统一添加Token import axios from 'axios'; import { Message } from 'element-ui'; // 假设使用Element UI的提示组件 import router from '@/router'; // 创建axios实例 const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 你的API基础地址 timeout: 15000 }); // 请求拦截器 service.interceptors.request.use( config => { // 在发送请求之前做些什么 const token = localStorage.getItem('access_token'); // 从本地存储获取Token if (token) { // 关键!按照后端约定的格式,在请求头中添加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: 200, data: ..., msg: 'success' } if (res.code !== 200) { // 业务逻辑错误,例如参数错误等,可以在这里统一提示 Message.error(res.msg || '请求失败'); return Promise.reject(new Error(res.msg || 'Error')); } else { return res.data; // 直接返回真正的数据部分,方便组件使用 } }, error => { // 对响应错误做点什么 (HTTP状态码非2xx) console.error('Response error:', error.response); if (error.response) { switch (error.response.status) { case 401: // 捕获401未认证错误,即我们后端返回的状态 Message.error('登录已过期或未认证,请重新登录'); // 清除本地无效的Token localStorage.removeItem('access_token'); // 跳转到登录页,并携带当前路由路径,以便登录后回跳 router.push(`/login?redirect=${encodeURIComponent(router.currentRoute.fullPath)}`); break; case 403: Message.error('权限不足,禁止访问'); break; case 404: Message.error('请求的资源不存在'); break; case 500: Message.error('服务器内部错误'); break; default: Message.error(error.response.data.msg || `请求错误: ${error.response.status}`); } } else if (error.request) { // 请求发出了但没有收到响应(网络错误、超时) Message.error('网络连接异常或请求超时'); } else { // 请求配置出错 Message.error('请求配置错误'); } return Promise.reject(error); } ); export default service;前端关键细节:
- Token存储:登录成功后,后端返回的Token应存储在
localStorage或sessionStorage中。localStorage关闭浏览器不会消失,适合“记住登录”场景;sessionStorage标签页关闭即消失,更安全。切勿存储在Vuex或内存变量中,页面刷新就没了。 - Header格式:必须严格按照后端
JwtFilter中isLoginAttempt方法判断的格式来设置请求头,即Authorization: Bearer <token>。Bearer后面有一个空格,这是一个常见的错误点。 - 拦截401:在响应拦截器中,必须捕获状态码为401的错误。这正是我们后端
JwtFilter的onAccessDenied方法返回的状态码。捕获后,应清除本地无效的Token,并引导用户重新登录。 - CORS配置:在Vue的开发环境(
vue.config.js)或生产环境服务器上,需要确保允许携带认证头。对于开发环境代理,通常不需要额外配置。如果直接跨域请求,后端SpringBoot需要配置CORS以允许Authorization头。
// SpringBoot CORS 配置示例 @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") // 对所有接口 .allowedOriginPatterns("*") // 允许所有源,生产环境应指定具体域名 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .exposedHeaders("Authorization") // 如果前端需要从响应头读Token,则暴露此头 .allowCredentials(true) // 允许携带Cookie等凭证,如果用到的话 .maxAge(3600); } }6. 问题排查清单:当“没有认证”再次出现时
即使按照上面的步骤配置好了,在复杂的生产环境中,问题仍可能间歇性出现。下面是一个系统性的排查清单,当问题复现时,可以按照这个顺序进行检查:
检查网络请求:打开浏览器开发者工具的“网络(Network)”标签。
- 目标请求的Header中是否有
Authorization头?格式是否正确(Bearer <token>)? - Token值本身是否正确?是否已经过期(可以通过 jwt.io 解码查看
exp字段)?是否被篡改? - 是不是OPTIONS预检请求失败了?查看在目标请求之前,是否有一个
OPTIONS方法的请求返回了非200状态码(如401)?如果是,说明CORS预检被拦截,需要检查后端JwtFilter或CORS配置是否正确放行了OPTIONS请求。
- 目标请求的Header中是否有
检查后端日志:这是定位问题最直接的地方。在Shiro抛出认证异常时,通常会有日志。
- 查看
JwtFilter的isAccessAllowed和executeLogin方法:添加详细的日志,看是否进入了这些方法,executeLogin是否抛出了异常。 - 查看
UserRealm的doGetAuthenticationInfo方法:jwtUtil.verifyToken(token)是否返回false?解析用户名是否失败?业务状态检查是否不通过? - 检查Shiro的过滤器链匹配:确认你当前请求的URL路径,是否真的被
/api/**规则匹配,并应用了jwt过滤器?有没有可能被更早的规则(如anon)匹配了?或者被Spring Security等其他安全框架拦截了?(确保项目中没有同时引入Shiro和Spring Security)。
- 查看
检查Token的生命周期与刷新:
- 并发请求与Token过期:假设Token有效期是30分钟,用户在29分59秒时发起一个长请求,服务器在30分01秒时才处理到这个请求并进行Token验证,此时Token已过期。可以考虑在Token临近过期时(如剩余5分钟),由后端在响应头中返回一个新的Token,前端自动更新本地存储。
- 多标签页问题:用户在标签页A登录,获得了Token。在标签页B直接打开系统,如果应用初始化时没有从存储中读取Token并设置到axios拦截器,那么B页面的请求就不会携带Token。确保你的前端应用在初始化(如
main.js或根组件created钩子)时,会从存储中读取Token并设置到axios实例上。
检查缓存与上下文:
- Shiro的Subject绑定:在Web环境中,
Subject是绑定到当前线程的。如果你在异步任务(如@Async、CompletableFuture)中调用Shiro的相关方法,可能会因为线程切换导致找不到认证信息。需要手动将Subject的上下文传递到子线程。 - Realm的授权信息缓存:如果启用了缓存,检查缓存是否被意外清空,或者缓存Key冲突导致取到了错误的授权信息。
- Shiro的Subject绑定:在Web环境中,
终极武器:调试与模拟:
- 使用Postman或Curl完全模拟前端的请求(包括完全相同的URL、Header、Body),排除前端代码的干扰。
- 在后端
JwtFilter和UserRealm的关键位置打上断点,一步步跟踪执行流程,观察变量状态,这是解决复杂诡异问题最有效的方法。
7. 进阶考量与优化建议
解决了基本问题后,我们可以思考如何让这套体系更健壮、更高效。
Token刷新机制:不要让用户频繁登录。可以实现一个
/api/auth/refresh接口,接收一个有效的、未过期的旧Token,返回一个新的Token。前端在收到401错误时,先尝试用旧的Refresh Token(一种有效期更长的特殊Token)去调用刷新接口,获取新Token后重试原请求,对用户无感。如果刷新也失败,再跳转到登录页。细粒度权限控制:目前我们的
UserRealm的doGetAuthorizationInfo方法返回了null。在实际项目中,你需要在这里查询用户的角色和权限。为了性能,可以将这些信息在用户登录成功后,直接编码到JWT的Payload中(注意Payload不宜过大),或者存入Redis,Key为用户ID,并设置合理的过期时间。在doGetAuthorizationInfo中,先从JWT解析或Redis获取,避免每次都查数据库。分布式会话一致性(如果非纯无状态):如果你的应用不是完全无状态,或者需要支持类似“强制下线”的功能,单纯的JWT会有点吃力(因为JWT签发后无法直接作废)。这时可以引入一个轻量级的中心化存储,如Redis,来维护一个“Token黑名单”或“有效Token清单”。在
JwtFilter校验Token有效性时,除了检查签名和过期时间,还要去Redis里查一下这个Token是否被主动注销了。SecurityManager的更多配置:在
ShiroConfig中,我们还可以配置缓存管理器(CacheManager)、记住我管理器(RememberMeManager,在Token场景下可简化)等,以适应更复杂的需求。
回过头来看最初的那个线上故障,根本原因就是我们在集成Shiro时,直接使用了默认的、基于Session的过滤器链,而没有为无状态的Token认证定制过滤器和Realm。前后端分离架构改变了认证信息的传递和存储方式,而安全框架的配置必须与时俱进。通过自定义JwtFilter来拦截请求并提取Token,通过改造UserRealm的supports和doGetAuthenticationInfo来校验Token,最后在配置类中正确组装和注册这些组件,我们就能让Shiro在前后端分离项目中完美胜任权限验证的工作。记住,框架是死的,人是活的,理解原理,根据实际架构灵活改造,才是解决问题的王道。