项目标题: Springboot整合Shiro 完整版
Spring Boot 整合 Shiro,是 Java 后端做权限控制绕不开的一套经典组合。从内部管理系统到带角色区分的 SaaS 后台,到处都有它的身影。Shiro 这个名字看着陌生,但你只要理解“登录验证”“角色判断”“接口权限拦截”这三件事,就已经知道它大概能干什么了。这篇文章不打算讲那种过一遍配置就跑的 Demo,而是把完整的整合路径、核心原理和实际踩坑点串起来讲,适合正在做权限模块开发、或者准备给老项目加登录鉴权的朋友直接参考。
先给还没接触过的读者划个重点:Shiro 是一个轻量级的 Java 安全框架,主要负责认证、授权、会话管理和加密,Spring Boot 通过官方的 starter 可以非常方便地把这些能力集成进来。相对 Spring Security 那套复杂体系,Shiro 的 API 更简单直接,学习曲线友好很多,尤其适合权限模型不复杂、需要快速交付的项目。下面我按照从原理到实战的顺序,把整个整合过程完整拆开。
1. 为什么是 Shiro:轻量级权限框架的选型思考
1.1 和 Spring Security 相比,Shiro 到底赢在哪
很多团队在权限框架选型时,容易陷入“功能越强越好”的误区。Spring Security 背靠 Spring 生态,能力确实全面,但代价是庞大的过滤器链、复杂的配置项、抽象的概念层。做一个小型后台管理系统,如果就为了登录校验和角色判断去啃 Spring Security,前期的学习和调试成本往往比业务本身还高。
Shiro 的设计思路就直白得多:它把安全功能拆成几个清晰模块,核心概念少,API 直观。你不需要理解一堆抽象接口,只要会调用SecurityUtils.getSubject(),然后调用login()、hasRole()、isPermitted()这些方法,就能完成大部分需求。Spring Boot 整合 Shiro 后,依赖引入简单,配置类一个就能搞定,运行期也不会出现那种“配置改了一处,另一处莫名失效”的玄学问题。
选型这件事没有绝对对错,关键是看场景匹配度:
| 对比维度 | Shiro | Spring Security |
|---|---|---|
| 入门成本 | 低,核心概念少,半天可上手 | 高,过滤器链和配置体系复杂 |
| 功能覆盖 | 认证、授权、会话、加密,足够常见场景 | 认证、授权、OAuth2、LDAP、防护等全家桶 |
| 与 Spring Boot 整合 | 官方 starter,配置集中 | 深度集成,但也意味着更深的绑定 |
| 适合项目 | 中小型后台、内部系统、快速交付 | 大型复杂系统、统一安全平台、强规范团队 |
| 维护状态 | 社区活跃度稳定,文档沉淀多 | 迭代快,背靠 Spring 官方 |
我的个人倾向很简单:没有复杂的 OAuth2 需求,没有细到按钮级的动态权限矩阵,就选 Shiro。它把“能用、好维护”放在第一位,对于业务开发团队来说,这是更务实的选择。
1.2 一个典型的可落地场景
拿我经手过的一个某后台管理系统举例:用户登录、角色分管理员和普通操作员、菜单按角色展示、部分接口仅管理员可用。这套需求如果用 Spring Security 做,光是过滤规则和密码加密的配置就要写不少东西;换 Shiro,核心只需要一个 Realm、一个配置类、一个登录接口。这就是典型的“杀鸡不用牛刀”场景。
Shiro 特别适合那种权限规则比较稳定的业务:用户在表里,角色在表里,角色和菜单权限关联,登录后判断“有没有某角色”“有没有某权限”。如果你的项目正好是这种形态,下面这套整合方案可以直接套用。
2. 核心机制:理解 Shiro 的四大组件
2.1 Subject 是操作者的抽象
Subject 是 Shiro 里最常打交道的对象,你可以把它理解为“当前用户的门禁卡”。在 Web 应用中,每个请求进来,Shiro 都会把当前请求对应的用户信息绑定到 Subject 上。通过SecurityUtils.getSubject()随时能拿到它,然后做身份判断、角色判断、权限判断。
举一个更生活化的例子:Subject 就像你进办公楼时刷卡的那张工牌,工牌能不能刷开门禁,取决于发卡系统(SecurityManager)怎么校验,而你本人是谁、什么岗位,则由人事系统(Realm)提供。subject.login(token)是把身份信息递给门禁系统尝试认证,subject.isAuthenticated()是看看这次尝试是否成功,subject.hasRole("admin")是问工牌上有没有对应楼层权限。
这套抽象的好处是:无论你是传统 Web 应用、前后端分离项目,还是未来的微服务网关,Subject 的身份获取方式都是统一的。代码里不依赖 HttpSession,只依赖 Subject,这就为会话管理和扩展留了余地。
2.2 SecurityManager 是总调度,Realm 是数据源
SecurityManager 是 Shiro 的心脏,所有认证、授权、会话操作最终都由它协调。它不直接查数据库,而是把“查数据”这件事委托给 Realm。Realm 就是连接 Shiro 和业务数据的桥梁,你需要自己实现两个方法:
doGetAuthenticationInfo:根据用户名查账号信息,返回密码和凭证盐值,Shiro 用它完成密码比对doGetAuthorizationInfo:根据已认证的用户名查角色和权限集合,Shiro 用它做授权判断
打个比方:SecurityManager 是保安队长,Realm 是人事档案室。保安队长不会背下所有人的信息,有人来刷卡,他就把工牌信息递给档案室核对;核对通过,再依据档案里的岗位权限决定你能进哪层楼。你的业务系统里,用户、角色、权限怎么存都行,Shiro 不关心,只要 Realm 能把结果查出来并按标准结构返回就行。
这里有个容易被忽略的设计点:Realm 的认证和授权方法是分开触发的。subject.login()只触发认证方法,subject.hasRole()、@RequiresPermissions注解才触发授权方法。所以你别指望登录时把权限一次性全查出来缓存住,授权信息默认是按需加载的,除非自己配置缓存。
2.3 过滤器链:URL 级别的拦截规则
Shiro 在 Web 环境下的鉴权主要是通过过滤器链实现的。它的设计很类似 Spring MVC 的拦截器:一组规则按顺序排列,请求进来时从上到下匹配,一旦命中就执行对应过滤器,执行完就不继续往下匹配了。
过滤器链的配置格式是“URL 表达式 = 过滤器名称”,我把常用的内置过滤器整理成了一张表:
| 过滤器名 | 作用 | 使用示例 |
|---|---|---|
| anon | 匿名可访问,不做登录校验 | /login = anon |
| authc | 必须登录才能访问 | /** = authc |
| logout | 退出登录,会调用 subject.logout() | /logout = logout |
| roles | 必须拥有指定角色,支持逗号分隔多个 | /admin/** = roles[admin] |
| perms | 必须拥有指定权限 | /user/add = perms[user:add] |
| user | 已登录或通过记住我功能访问 | /** = user |
配置顺序是整个联动最关键的环节。很多人第一次整合时,把书写的规则顺序搞反了,结果静态资源全被拦截、登录接口进不去。原因很简单:/**这种通配规则命中一切路径,如果放在前面,后面的精确规则永远轮不到执行。真正合理的配置一定要把精确规则、放行规则写前面,把兜底拦截规则写最后。
3. 实操落地:Spring Boot 整合 Shiro 的完整步骤
3.1 引入依赖的版本选型
整合第一步是加依赖。这里要注意一个版本坑:Shiro 官方提供的 starter 版本号和 Spring Boot 版本对应关系要理清。Spring Boot 2.x 项目建议使用 1.8.0 以上的shiro-spring-boot-starter,它的传递依赖会带好 shiro-core、shiro-web、shiro-spring 等模块,不需要再逐个加。
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.8.0</version> </dependency>如果你使用 Spring Boot 3.x,需要留意底层 Jakarta EE 的兼容问题,Shiro 2.0 以上的版本才完整支持,这一点在引入前务必确认。另外,Starter 本身不自带 Redis 会话模块,后面要用 Redis 管理会话时,需要额外引入对应的第三方集成包或自己写 SessionDAO。
验证依赖是否引入成功,最简单的办法是启动项目后看控制台有没有打印 Shiro 的过滤器初始化日志。如果看到类似“ShiroFilter registered”的信息,说明过滤器已经挂到了 Spring Boot 的过滤器链里。
3.2 ShiroConfig 配置类的核心写法
整合的精华都在这个配置类里。我在项目里的标准写法是这样的:
@Configuration public class ShiroConfig { @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 未登录时跳转的地址,前后端分离项目通常设定为返回 JSON 的接口 factoryBean.setLoginUrl("/login"); // 登录成功后的默认跳转地址(非前后端分离场景使用) factoryBean.setSuccessUrl("/index"); // 无权限时的跳转地址 factoryBean.setUnauthorizedUrl("/403"); // 注意:必须使用 LinkedHashMap,保证规则按定义顺序生效 Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); // 放行静态资源和登录接口 filterChainDefinitionMap.put("/css/**", "anon"); filterChainDefinitionMap.put("/js/**", "anon"); filterChainDefinitionMap.put("/images/**", "anon"); filterChainDefinitionMap.put("/login", "anon"); filterChainDefinitionMap.put("/logout", "logout"); // 角色和权限控制 filterChainDefinitionMap.put("/admin/**", "roles[admin]"); filterChainDefinitionMap.put("/user/delete", "perms[user:delete]"); // 兜底:其余所有请求必须登录 filterChainDefinitionMap.put("/**", "authc"); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } @Bean public DefaultWebSecurityManager securityManager() { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myRealm()); return securityManager; } @Bean public MyRealm myRealm() { MyRealm realm = new MyRealm(); // 在这里可以配置密码匹配器,后面细讲 return realm; } }这段配置有几个值得展开的地方。第一个是ShiroFilterFactoryBean本身是一个 Spring Boot 的FilterRegistrationBean,Spring Boot 启动时会把 Shiro 的过滤器注册到整个应用的最外层。这意味着 Shiro 的过滤器优先级高于 Spring MVC 的拦截器,也高于大部分业务过滤器,所以配置链在这里出问题,影响面会是全局的。
第二个重点是loginUrl的作用。在传统多页面应用里,未登录访问受保护页面时,Shiro 会直接 302 重定向到这个地址。但在前后端分离项目里,你不想让浏览器跳页面,而是希望后端返回一个 JSON 告诉前端“未登录”。这种情况就不能只依赖loginUrl了,后面我会单独讲如何处理。
3.3 自定义 Realm 和密码匹配器
Realm 是权限数据来源的入口。先写一个最常用的实现,继承AuthorizingRealm,并简单实现认证和授权两个方法:
public class MyRealm extends AuthorizingRealm { @Autowired private UserService userService; @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); Set<String> roles = userService.findRolesByUsername(username); Set<String> perms = userService.findPermsByUsername(username); info.setRoles(roles); info.setStringPermissions(perms); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username = (String) token.getPrincipal(); User user = userService.findByUsername(username); if (user == null) { // 返回 null,Shiro 会抛 UnknownAccountException return null; } // 如果数据库密码有盐值,第三个参数需要传 ByteSource.Util.bytes(salt) return new SimpleAuthenticationInfo(username, user.getPassword(), getName()); } }这段代码逻辑本身不难,但隐藏着一个关键选择:密码怎么存储、怎么校验。如果数据库里存的是明文或简单 MD5,直接返回密码就行,但这种做法在现代安全要求下基本不合格。正确做法是加盐哈希,我在配置 Realm 时会绑定一个HashedCredentialsMatcher:
@Bean public MyRealm myRealm() { MyRealm realm = new MyRealm(); HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("MD5"); matcher.setHashIterations(1024); realm.setCredentialsMatcher(matcher); return realm; }这样配置以后,Shiro 在认证时会把用户输入的密码用相同算法和迭代次数哈希,再和数据库里的密文比较。注意这里的迭代次数要和注册用户时生成密文的次数完全一致,否则永远登录不上。实际项目中我更推荐用 SHA-256 甚至 BCrypt,迭代次数也不要拍脑袋写,一般建议至少 1024 次,再结合项目性能做权衡。
3.4 登录、退出、获取当前用户:接口层实现
配置和 Realm 都齐了以后,登录接口就好写了。核心代码是创建UsernamePasswordToken,然后交给subject.login()统一处理:
@RestController public class AuthController { @Autowired private UserService userService; @PostMapping("/login") public Result login(String username, String password, Boolean rememberMe) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken(username, password); if (rememberMe != null && rememberMe) { token.setRememberMe(true); } try { subject.login(token); String currentUsername = (String) subject.getPrincipal(); return Result.success(userService.findByUsername(currentUsername)); } catch (UnknownAccountException e) { return Result.error("账号不存在"); } catch (IncorrectCredentialsException e) { return Result.error("密码错误"); } catch (LockedAccountException e) { return Result.error("账号已被锁定"); } catch (AuthenticationException e) { return Result.error("认证失败,请联系管理员"); } } @GetMapping("/logout") public Result logout() { SecurityUtils.getSubject().logout(); return Result.success("退出成功"); } @GetMapping("/current") public Result current() { Subject subject = SecurityUtils.getSubject(); if (!subject.isAuthenticated()) { return Result.error("未登录"); } String username = (String) subject.getPrimaryPrincipal(); return Result.success(userService.findByUsername(username)); } }这里几个细节提醒一下。第一,异常捕获顺序不能乱,AuthenticationException是父异常,必须放在最后,否则子异常永远捕获不到。第二,subject.login()一旦抛出异常,说明认证失败,此时 Subject 仍然处于未认证状态,可以继续尝试登录。第三,很多新手会直接subject.isAuthenticated()判断登录态,但这个方法只对登录认证有效,如果用了 rememberMe,isAuthenticated()返回 false,而isRemembered()返回 true,这两个状态要区分清楚。
4. 授权模型:从数据表设计到接口控制
4.1 RBAC 表结构与数据查询
Shiro 不强制你用什么表结构,只要能在 Realm 里查到角色和权限集合即可。最常见的 RBAC 模型是五张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。核心字段大概是这样:
用户表 user(id、username、password、salt、status),角色表 role(id、role_name、description),权限表 perm(id、perm_code、perm_name),关联表存的就是彼此的主键映射。
权限字符串的命名习惯建议用冒号或点号分隔,例如user:add、user:delete、order:view,这样在注解和过滤器里写起来语义清晰。Realm 的授权方法并不关心这些字符串的内部结构,它只是把集合返回给 Shiro,Shiro 在判断perms[user:delete]时会完全匹配权限字符串。
需要注意的一个查询性能细节:doGetAuthorizationInfo会在每次调用hasRole、isPermitted或触发权限注解时被调用,如果没有缓存,高并发场景下会对数据库造成压力。Shiro 自带缓存抽象,配合 Redis 或 Ehcache 可以缓存授权信息,这点在后面会话管理部分会提到。
4.2 注解授权与 URL 授权的选择艺术
过滤器链的roles[...]和perms[...]属于粗粒度控制,适合整个目录或接口统一拦截。而细粒度的方法级控制,推荐使用注解。Shiro 提供了一组注解:
@RequiresRoles("admin") @GetMapping("/admin/list") public Result adminList() { ... } @RequiresPermissions("user:delete") @DeleteMapping("/user/{id}") public Result deleteUser(@PathVariable Long id) { ... }注解的直接优点是:权限要求和接口代码放在一起,可读性好,改权限时一眼能看到对应关系。而且注解在 Service 层也能用,不局限于 Controller,灵活性更高。
但注解生效有个前提——Spring 的 AOP 代理要起作用。如果发现加注解后权限不拦截,先检查自己有没有注册AuthorizationAttributeSourceAdvisor和DefaultAdvisorAutoProxyCreator。在 Spring Boot 的自动配置下,引入了 Shiro 的 starter 后多数情况已经生效,但如果你自定义了配置类把某些 Bean 覆盖掉了,就需要手动补上:
@Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } @Bean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator(); creator.setProxyTargetClass(true); return creator; }另一个常见失效场景是同类内部方法自调用,比如一个 Controller 里方法 A 调方法 B,B 上有权限注解,由于 AOP 代理机制,自调用不会经过代理,注解就不会生效。碰到这种情况,把 B 抽到另一个 Bean 里,或者改造成通过代理对象调用,问题就解决了。
4.3 前端菜单和按钮的权限控制思路
注解和 URL 过滤负责后端防线,前端展示层面的控制同样不能少。传统 JSP 项目可以直接用 Shiro 提供的 JSP 标签,但在前后端分离项目里,更常见的做法是登录成功后返回当前用户的角色和权限集合,前端根据这些信息控制菜单显隐和按钮可用状态。
具体实现上,我一般会提供一个/current/perms接口,返回当前用户的权限字符串列表。前端拿到后存入状态管理工具,做菜单过滤时直接用字符串匹配判断。这种方案的好处是前后端规则统一,权限数据只维护一份,不会出现前端写死按钮、后端却校验不到位的漏洞。记住一个原则:前端控制只影响体验,真正的安全边界永远在后端接口上。
5. 会话与缓存:登录态的扩展处理
5.1 默认 Session 机制是怎么工作的
Shiro 不直接依赖 Servlet 容器的 HttpSession,它有一套自己的会话抽象。默认情况下,DefaultWebSessionManager会在 HttpSession 的基础上做了一层包装,或者完全由 Shiro 自己管理会话。对于单机部署的小项目,默认机制够用,没有额外配置成本。
但一旦涉及集群部署、负载均衡,问题就来了:不同节点各有各的会话,用户在 A 节点登录后,请求被转发到 B 节点,B 认不出这个会话,又得重新登录。这时候就得把会话数据收敛到公共存储,比如 Redis。这也解释了为什么很多项目走到一定程度后,会把 Shiro 的 SessionManager 换成 Redis 实现。
5.2 用 Redis 管理会话的核心改造
改造思路不复杂:自定义SessionDAO,把会话数据丢到 Redis,由 Redis 统一保存和读取。第三方库shiro-redis是市面上用得比较多的方案,核心配置如下:
@Bean public RedisSessionDAO sessionDAO() { RedisSessionDAO sessionDAO = new RedisSessionDAO(); sessionDAO.setKeyPrefix("shiro_session:"); return sessionDAO; } @Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO sessionDAO) { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionDAO(sessionDAO); sessionManager.setGlobalSessionTimeout(1800000); sessionManager.setSessionIdUrlRewritingEnabled(false); return sessionManager; }这里有个容易踩的坑:Redis 里要存的 Session 对象本身必须可序列化。如果你往 Session 属性里塞了一个没实现Serializable的普通对象,存的时候直接报序列化异常,整个请求就挂了。我的习惯是 Session 里尽量只存主键、用户名这种基本类型,业务数据用的时候再查库,既避免序列化问题,也减少了 Redis 里的大对象体积。
setSessionIdUrlRewritingEnabled(false)也很重要,它用来禁止 Shiro 把 sessionId 拼到 URL 后面,防止会话 ID 泄露。默认值本来就有风险,建议显式关掉。
5.3 RememberMe 记住我功能的实现与隐患
实现“记住我”很简单,登录时设置token.setRememberMe(true),然后在配置里加一个 CookieRememberMeManager:
@Bean public CookieRememberMeManager rememberMeManager() { CookieRememberMeManager manager = new CookieRememberMeManager(); SimpleCookie cookie = new SimpleCookie("rememberMe"); cookie.setHttpOnly(true); cookie.setMaxAge(7 * 24 * 60 * 60); manager.setCookie(cookie); // 生产环境务必自定义加密密钥,替换掉默认值 manager.setCipherKey("your-custom-cipher-key".getBytes(StandardCharsets.UTF_8)); return manager; }需要清醒认识到:rememberMe 生成的凭证 Cookie 是对用户身份加密后的数据,它不会因为密码修改而失效,也不依赖服务端会话存储。如果 Cookie 被窃取,攻击者可以长时间冒充用户身份。所以涉及资金、敏感数据的系统,我对这个功能的建议是关闭或者缩短有效期,同时处理好密码修改、封号后让旧凭证失效的问题——这一点 Shiro 默认机制并不保证,需要业务层额外处理。
6. 实战问题速查:这些坑我都替你们踩过了
6.1 过滤器链顺序导致全部请求被拦截
这是最高频的问题,典型症状:静态资源加载不出来、登录接口返回“未认证”的 JSON 或直接跳转登录页。先检查配置里的filterChainDefinitionMap是不是用了普通的HashMap。如果是,恭喜你找到一个隐蔽的坑——HashMap 不保证顺序,Shiro 按迭代顺序匹配规则,顺序一旦错乱,规则就全乱套。
正确的做法是始终用LinkedHashMap,并保证精确规则在前、兜底规则在后:
// 错误的写法(概念示范) map.put("/**", "authc"); map.put("/login", "anon"); // 永远不会被执行 // 正确的写法 map.put("/login", "anon"); map.put("/static/**", "anon"); map.put("/**", "authc");还有一个容易忽略的点:anon并不是“完全不经过过滤器”,只是代表“不执行认证相关拦截器”,如果项目还挂了其他全局过滤器(比如 CORS 过滤器),它们的逻辑仍然会执行。
6.2 Realm 注入 Service 产生循环依赖
在 Realm 里直接@Autowired一个 Service,有时候会碰到启动失败,报循环依赖。原因往往是 Service 的初始化又间接依赖到 SecurityManager 或 Shiro 相关的 Bean。排查方向是看完整的@RestController启动报错栈,定位到是哪一对 Bean 互相引用。
我的解决办法有两种。一种是懒加载注入:
@Lazy @Autowired private UserService userService;另一种更彻底的方法:把 Realm 切换到构造器注入,或者把数据查询逻辑抽成一个独立的UserQueryService,让 Realm 只依赖这个查询服务,避免和 SecurityManager 的初始化路径交叉。注意改完配置后一定要重启跑一遍登录流程,因为循环依赖有时候只在特定清理缓存后才暴露出来。
6.3 权限注解不生效的排查步骤
我按以下顺序排查,基本能快速定位:
- 确认注解是写在
public方法上,写在private方法上 AOP 管不到 - 确认类被 Spring 容器管理,检查类上有没有
@Service、@Controller等注解 - 确认
AuthorizationAttributeSourceAdvisor和DefaultAdvisorAutoProxyCreator是否正常注册 - 确认不是同类内部自调用导致代理失效
- 确认拦截的异常有没有被全局异常处理器吞掉,比如
UnauthorizedException被统一转成了 200 响应,表面看起来是“不生效”
特别提醒一下第五点。实际遇到过这种情况:权限注解明明拦截了,但全局异常处理器把AuthorizationException捕获后返回了Result.success("无权限"),前端看到状态码是正常成功,就以为接口没做鉴权。排查时先把全局异常处理里和 Shiro 相关的异常分类打日志,不要混在一起。
6.4 前后端分离的跨域与会话失效处理
前后端分离项目里,前端和后端域名不同,跨域请求会先发送 OPTIONS 预检。Shiro 的过滤器链如果不放行 OPTIONS,预检请求会被authc拦截,浏览器直接报跨域错误,而后端日志看不到业务异常,因为这个请求根本没进到 Controller。解决方案是在过滤器链最前面放行 OPTIONS:
filterChainDefinitionMap.put("/**", "anon");这里有个权衡:全部放行看起来太宽松,但 aon 只表示跳过 Shiro 的认证拦截,如果项目自己有全局过滤器,仍然会处理请求。更稳妥的做法是自定义一个过滤器,专门处理 OPTIONS 请求直接返回放行,同时保留其他 URL 的认证规则。基础实现:
public class CorsOptionsFilter extends AuthenticatingFilter { @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) { return true; } return super.preHandle(request, response); } }然后把过滤器注册到 ShiroFilterFactoryBean 上,并在定义链时把它作为第一个规则。
6.5 会话过期导致 Ajax 请求返回登录页
默认配置下,会话过期后访问受保护接口,Shiro 会直接返回重定向响应,目标是loginUrl。这个行为在后端返回 HTML 页面时没问题,但在前后端分离项目里很尴尬:前端发一个 Ajax 请求,期望拿到 JSON,结果返回 302,浏览器跟着跳转到登录页,前端拿到的是 HTML 页面结构,报错也不好排查。
我的做法是自定义一个认证过滤器,重写onAccessDenied方法,判断请求是否是 Ajax 请求(看请求头X-Requested-With或Accept),如果是就直接返回 401 JSON,否则才走默认重定向逻辑。外层再配合全局异常处理,让前端统一监听 401 状态码,主动跳转到登录页面。
6.6 序列化异常:明文与反序列化双重坑
一个典型的报错是org.apache.shiro.session.SerializationException: Unable to serialize。第一次碰到时,我排查了很久,最后发现是自定义的 User 对象没有实现Serializable。使用 Redis 会话存储后,任何要写进 Session 的对象都必须可序列化,这个约束容易被忽略。
解决方式有两个方向。第一,所有可能存进 Session 的实体类实现Serializable接口并声明serialVersionUID。第二,更推荐的方式是 Session 里只放用户 ID,接口需要用户信息的时候实时查库,这样既规避序列化问题,又避免 Session 里携带过期数据。后一种方式在集群环境下尤其有优势,因为避免了 Redis 大对象和缓存一致性的双重风险。
结尾:一点个人体会
权限这块工作,框架从来不是最难的,难的是把规则梳理清楚。Shiro 本身给了你很清晰的模型:Subject 是谁、Realm 怎么查数、过滤器链怎么拦,这三件事想明白了,整合流程自然就顺了。我在实际项目中最大的感受是,不要一开始就贪多,先把登录、注销、角色拦截跑通,再逐步加 Redis 会话、权限注解、动态权限这些进阶能力。另外强烈建议在项目早期就把密码加密、会话时效、权限审计这些安全基线定好,不要等上线前才补,那种“最后补安全”的滋味,经历过的人都懂。如果你正在做权限模块,希望这篇整理能让你少走几步弯路。