最近帮一家做企业系统的公司处理线上权限问题,代码用的是 Apache Shiro。排查到凌晨,发现报错的原因特别可笑:某个定时任务里调用了Subject.logout(),导致后续用户的会话直接被顶掉。我一边吐槽一边翻他们工程里的代码,看到十几个类里面@RequiresPermissions和@RequiresRoles注解用得飞起,但问负责人“你们这个Realm里的缓存什么时候失效”,对方一愣:“我们用的不是默认的吗?”那一刻我特别理解一个词——从精通到陌生。
这个词放在 Shiro 身上非常贴切。很多开发者简历上都写过“精通 Shiro”,能熟练配置ShiroFilterFactoryBean,能背出anon、authc、user这些过滤器名称,遇到新项目三天就能集成完。但一旦绕过“百度模板三件套”,进入真实的业务场景,比如多租户数据权限、分布式会话、自定义注解鉴权、密码加盐策略调整,立刻就从“精通”跌回“陌生”。这个标题想说的就是这种现象:不是 Shiro 太难,而是大多数人从一开始学到的就是“复制粘贴配置”,根本没有深入到框架的骨子里去。这篇文章我不想写成 Shiro 的 API 手册,而是想以复盘的方式,把那些容易让人从“熟悉”变“陌生”的核心点全部拎出来,配合代码和踩坑记录,讲清楚原理、流程、实操方法和排查思路。适合谁看?已经用过 Shiro 但遇到深层问题就来来回回找不到原因的人,以及那些准备在简历上写“精通 Shiro”但心里发虚的人。看完之后你至少能回答三个问题:Shiro 的认证授权到底是怎么跑通的?Realm 里面到底该写什么?生产环境里的那些诡异权限 bug 又是怎么来的?
1. 项目概述:为什么一门技术会“从精通到陌生”
1.1 从一次权限事故说起
先说一下开头提到的那次事故,它特别典型。那家公司做的是一个面向医院的后台管理系统,权限模型很复杂,角色分医生、护士长、管理员、超级管理员四层,菜单和按钮级权限都有。线上反馈的 bug 是这样的:某个管理员用户查患者列表时,突然提示“当前账号已被登录”或者直接 401。我远程看日志,发现代码里有这么一段:
@Scheduled(cron = "0 */30 * * * * ?") public void refreshSessionData() { // 拉取一批失效账号,强制下线 Subject subject = SecurityUtils.getSubject(); subject.logout(); }定时任务里调用的SecurityUtils.getSubject()拿的是当前线程绑定的主体,并不是“系统中所有在线用户”。一旦定时任务在某个瞬间被触发,并且运行在线程池里,它可能把真正用户请求线程的Subject当成自己的来登出。这属于典型的“把 Shiro 当黑盒用”的后果:知道getSubject()能返回一个主体,却不理解它依赖ThreadContext,也不理解会话与线程之间的绑定关系。从现象来看,这不是 Shiro 的 bug,而是开发者对 Shiro 上下文模型的理解出了问题。但同样的问题也发生在很多其他项目里:把ShiroUtils.getSubject()到处传,把SecurityUtils当成万能入口,根本不关心当前线程是否有登录状态。
1.2 所谓的“精通”到底缺了什么
我在平时带人的时候特别怕听到“精通 Shiro”四个字。不是不信任,而是这四个字对应的能力门槛太高了。真正的精通,至少要具备四层能力。
第一层是配置能力,知道怎么导入依赖、怎么声明ShiroFilterFactoryBean、怎么把几个Realm塞进SecurityManager。第二层是模型能力,知道Subject、SecurityManager、Realm三个核心抽象各自的职责,知道认证和授权分别走什么链路。第三层是扩展能力,能根据业务定制Realm、SessionDAO、CredentialsMatcher、PermissionResolver,知道什么时候要覆盖默认实现。第四层才是排错能力,面对“登录成功但接口 401”“权限注解失效”“密码永远匹配不上”这类问题,能不看堆栈就猜到大概挂在哪一层。
大部分人的“精通”停在了第一层,而且第一层还是靠着从网上复制的一份整合配置撑起来的。一旦配置文件的写法因为 Spring Boot 版本升级而略有改动,就开始陌生了,因为从来不知道那些配置背后的类是在干什么。
1.3 Shiro 到底在解决什么问题
Shiro 是一个安全框架,它把自己定位成“轻量级、零侵入”的权限方案,核心能力就四块:认证(Authentication)、授权(Authorization)、会话管理(Session)和加密(Cryptography)。这里有个容易搞错的概念:Shiro 并不是像 Spring Security 那样重度绑定 Web 层和 Spring 容器,它更偏底层,甚至不依赖 Spring 也能跑。你可以在一个纯 Java 的 application 里创建DefaultSecurityManager,然后subject.login(token)完成一次认证。这种设计让它在很多非 Spring 项目里也能用,但也导致了后来的一个问题:不同项目里的 Shiro 配置风格差异极大,有的用spring-boot-starter-shiro,有的用shiro-spring加 XML,有的干脆在@Configuration里手工构造DefaultWebSecurityManager。看得多了你会发现,真正支撑这些配置的全是那几个核心接口。把这几个接口搞懂了,无论怎么配都不怕。
2. 核心原理拆解:先把三件套彻底搞明白
2.1 SecurityManager:总控中心
SecurityManager是 Shiro 的心脏,所有认证、授权、会话相关操作最终都会汇聚到它那里。你可以把它理解成一个部门的总负责人,自己本身不干具体活,但是所有任务都要经过它来分派。
具体分派逻辑是这样的:它内部持有三个组件,Authenticator(认证器)、Authorizer(授权器)、SessionManager(会话管理器)。Subject.login()调用最终委托给Authenticator.authenticate(),Subject.isPermitted()委托给Authorizer.isPermitted(),Subject.getSession()委托给SessionManager.getSession()。在 web 应用中,SecurityManager还持有一个SessionDAO和CookieRememberMeManager,分别是会话存取和“记住我”功能的实现。
设计上 Shiro 有一个很实用的抽象:SecurityManager接口本身不直接实现认证授权逻辑,而是组合了多个子组件。这样改认证策略时不需要动SecurityManager,替换一个Authenticator就够。我在某个多账号类型项目里,就同时配了三套Realm,分别处理用户、第三方账号和内部服务账号,然后通过ModularRealmAuthenticator在认证时按token类型自动选择具体的Realm。这也意味着,SecurityManager的配置是你集成时的第一道关卡,它决定了后续所有行为。
2.2 Subject:门面与用户代理
Subject可以理解成一个“当前用户代理”。它不是一个简单的实体对象,而是代表当前用户与系统之间的安全视图。在 Web 项目中,Subject与当前请求线程绑定,内部会把用户主键、会话 ID、权限信息等放到ThreadContext里,所以同一线程内多次获取subject拿到的是同一个主体。
这里特别容易出问题的点在于:Subject不是全局唯一的。登录后拿到一个Subject,它只在当前线程里有效。如果把这它塞进静态变量,或者在线程池异步任务里访问,大概率拿不到登录状态。对于刚接触 Shiro 的人来说,还有一个更常见的误解:认为subject.login(token)之后,所有权限就直接挂在这个对象上了。实际并不是,Subject只知道当前用户标识,具体有什么角色、什么权限,是授权阶段由Realm查出来的,而且带缓存的话还可能查出来的是一份旧数据。你需要自己控制缓存更新时机,比如角色变更后调用subject.getSession()清掉旧的授权缓存,或者让Realm里授权缓存按时过时。
2.3 Realm:唯一需要开发者认真写的地方
框架里Realm是最贴近业务的部分。Authenticator负责验证身份,但它不关心用户数据存在哪、密码怎么比对,这些都是Realm干的活。Realm相当于“数据访问适配层”,把外部存储(数据库、LDAP、缓存)里的用户信息转换成 Shiro 能识别的安全数据。
一个标准自定义Realm需要重写两个方法:
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token); protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals);第一个方法在登录时被调用,需要根据token.getUsername()(或 token 里的身份标识)去数据库查用户,返回SimpleAuthenticationInfo,里面包含用户的身份标识、密码、盐值信息。第二个方法在授权时被调用,在这个方法里根据用户名查询角色和权限集合,封装进SimpleAuthorizationInfo。
这里有个最关键的细节:doGetAuthenticationInfo只负责把存储中的账号密码返回给 Shiro,真正的密码比对发生在CredentialsMatcher里面,不在 Realm 里。很多人误以为在这个方法里自己if ("正确密码".equals(password))判断就算认证了,这个理解是错的。如果你返回了AuthenticationInfo,而密码不匹配,Shiro 会直接抛IncorrectCredentialsException。所以你可以把整个流程理解成:Realm提供“依据”,Matcher做“验证”,二者缺一不可。
2.4 认证与授权的完整链路
把职责分清楚后,完整链路就很好懂了。
认证链路的简化步骤是:调用subject.login(token),SecurityManager收到请求后,把 token 交给Authenticator,Authenticator找到匹配的Realm,调用doGetAuthenticationInfo拿到存储侧的账号信息,然后把存储密码和 token 中的输入密码一起交给CredentialsMatcher比对。一致则认证通过,Subject内部保存登录身份;不一致抛异常。
授权链路则分成“编码式”和“注解式”两条路线。编码式是subject.isPermitted("user:edit"),注解式是@RequiresPermissions("user:edit")。无论哪条,最终都会走到Authorizer,然后由Realm的doGetAuthorizationInfo返回当前用户的权限集合,Shiro 再拿目标权限字符串和集合做匹配。匹配逻辑默认是按字符串精确匹配,但PermissionResolver可以解析出通配符模式,比如user:*就可以匹配user:edit和user:view。这一点也是很多从“精通”变“陌生”的开发者最容易忘记的:通配符不是天然生效的,需要理解WildcardPermission的解析层级。
3. 实操落地:从零集成一套可用的权限系统
3.1 工程准备与依赖
实操部分我用一个常见的 Spring Boot 单体后台管理系统来做例子。不要用网上流传的那种老掉牙的 XML 配置,直接用 Java Config 方式,这样逻辑更清晰,也更好排查问题。先引入依赖:
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.13.0</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> <version>1.13.0</version> </dependency>这里有一个提醒:Shiro 1.13 之后对 Spring Boot 3 的支持才算比较顺,如果项目还在用shiro-spring-boot-starter,注意版本的兼容性,很多“从精通到陌生”的尴尬就是从依赖冲突开始的。自己搭项目时最好用shiro-spring和shiro-core组合,手动声明ShiroFilterFactoryBean,不依赖 starter 的自动配置。
工程结构上,我会把安全相关的类单独放在一个security包,包含UserRealm、ShiroConfig、SessionManager(如果有特殊会话需求)、PasswordHelper和一个SecurityUtils的工具封装。这个分包看似简单,但能让你在排查问题时快速定位是配置问题还是业务问题。
3.2 自定义 Realm 的完整实现
下面是一份可以直接拿来改的UserRealm:
public class UserRealm extends AuthorizingRealm { @Autowired private UserService userService; @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username = (String) token.getPrincipal(); User user = userService.findByUsername(username); if (user == null) { // 抛出的异常类型需要和全局异常处理匹配 throw new UnknownAccountException("账号不存在"); } if (user.getStatus() != 1) { throw new LockedAccountException("账号被锁定"); } // 返回身份信息、数据库密码、盐值 return new SimpleAuthenticationInfo( user.getId(), user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName()); } @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; } }注意几个细节。第一,SimpleAuthenticationInfo的第一个参数我放的是user.getId()而不是username。这样后面授权查询时拿到的principal是用户主键,避免通过用户名二次查询。但这也意味着登录页接收的token里的 principal 和Realm里返回的 principal 不必保持一致,只要认证后 Shiro 内部保存的是你返回的那个就行。第二,盐值是ByteSource.Util.bytes(user.getSalt()),如果盐没设置,后面密码匹配器也要对应调整。第三,授权方法里建议开启缓存,否则每个接口每次鉴权都会查一次数据库权限列表,这个坑在并发量上来以后会特别明显。
3.3 配置 SecurityManager 与密码匹配器
密码匹配器的配置是很多人翻车的地方。Shiro 默认CredentialsMatcher是直接equals,数据库密码如果存的是密文,就必须配置HashedCredentialsMatcher,并指定哈希算法:
@Bean public HashedCredentialsMatcher credentialsMatcher() { HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("SHA-256"); matcher.setHashIterations(1024); matcher.setStoredCredentialsHexEncoded(true); return matcher; }我这里选 SHA-256 加 1024 次迭代,算是一个比较常见的平衡点。但要注意:匹配器上的这些参数必须和注册用户时生成密码的参数完全一致。很多项目出“密码明明没错但就是登不进去”的 bug,十有八九是注册时用 MD5 加密,登录时却配成了 SHA-256。所以密码生成工具类我一般单独封装,并保证注册和认证使用同一套参数:
public class PasswordHelper { public static final String ALGORITHM = "SHA-256"; public static final int ITERATIONS = 1024; public static String encrypt(String rawPassword, String salt) { return new SimpleHash(ALGORITHM, rawPassword, salt, ITERATIONS).toHex(); } public static String generateSalt() { return SecureRandomStringUtils.randomAlphanumeric(16); } }然后设置SecurityManager:
@Bean public DefaultWebSecurityManager securityManager(UserRealm userRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(userRealm); userRealm.setCredentialsMatcher(credentialsMatcher()); securityManager.setRememberMeManager(rememberMeManager()); return securityManager; }还有一处细节:DefaultWebSecurityManager默认会在内存里维护会话,单机没问题,但生产环境大多是多节点,我后面会专门说会话共享怎么处理。这里先保持单机配置能跑通。
3.4 登录接口与鉴权注解
登录接口本身简单,关键在于token类型和异常处理:
@PostMapping("/login") public Result login(@RequestBody LoginRequest request) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken(request.getUsername(), request.getPassword()); try { subject.login(token); return Result.ok("登录成功"); } catch (UnknownAccountException e) { return Result.error("账号不存在"); } catch (IncorrectCredentialsException e) { return Result.error("密码错误"); } catch (LockedAccountException e) { return Result.error("账号已被锁定"); } catch (AuthenticationException e) { return Result.error("认证失败:" + e.getMessage()); } }鉴权时,可以用注解方式,在 Controller 方法上加@RequiresPermissions("user:delete"),也可以编程式判断。注解方式更优雅,但它有一个前置条件:Spring 容器里的 Bean 方法必须被 Shiro 的 AOP 切面拦截。所以ShiroConfig里需要添加DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor,否则注解不生效。很多人项目里加了注解却没有任何权限控制,排查一圈发现就是少了这两个 Bean,这是导致“陌生”感的重灾区。
3.5 会话与缓存配置
默认会话存储在 JVM 内存中,重启即丢失,线上多节点也会各自为政。如果项目暂时不引入 Redis,可以先用自定义SessionDAO把会话放到 Redis。实现思路不复杂,继承AbstractSessionDAO,重写doCreate、doRead等方法,把Session序列化后存进 Redis。很多团队嫌麻烦直接用shiro-redis这个集成包,但它对 Shiro 版本很敏感,经常出现序列化兼容问题。我更推荐一个偏稳妥的思路:只把 Session 的 key 放在 Redis,value 通过 JSON 序列化,同时设置合理的过期时间。
这里还要注意“瘦会话”的概念。Shiro 默认会把整个 Session 对象存进SessionDAO,如果你的 Session 里塞了大量对象(有人图省事把整个用户对象塞进去),Redis 内存压力会非常大。更好的做法是只存userId+ 会话元信息,真正的业务数据通过缓存层查询。
4. 生产环境里的坑:我踩过和帮别人排过的雷
4.1 密码比对失败,问题往往在盐值
线上用户反馈“注册成功后第一次登录提示密码错误”,这个 bug 我见过不止一次。排查步骤很固定:先看注册时是否给用户生成了盐值,再看 Shiro 配置里credentialsMatcher是否启用了盐值加密,最后看SimpleAuthenticationInfo第三个参数传的盐是否和注册时一致。任何一个环节不对称,密码就会永远比对失败。
特别提醒一点,有些系统是历史系统迁移过来的,老用户密码是 MD5 无盐加密,新用户用 SHA-256 加盐。遇到这种混合场景,一个HashedCredentialsMatcher就不够用了,你需要自定义CredentialsMatcher,在里面根据用户版本选择不同的校验策略。这也是“从精通到陌生”的经典场景:你以为加密是统一的,结果历史包袱早已把统一性打破。
4.2 过滤器链配置顺序,一个顺序毁所有
ShiroFilterFactoryBean中的filterChainDefinitions是一个 LinkedHashMap,顺序敏感。常见错误是把/** = authc写在最前面,导致后面的anon、perms全都不生效,所有页面都得登录。正确的顺序应该从具体到通用:
filterChainDefinitionMap.put("/login", "anon"); filterChainDefinitionMap.put("/register", "anon"); filterChainDefinitionMap.put("/captcha", "anon"); filterChainDefinitionMap.put("/static/**", "anon"); filterChainDefinitionMap.put("/logout", "logout"); filterChainDefinitionMap.put("/admin/**", "roles[admin]"); filterChainDefinitionMap.put("/**", "authc");还有两个容易忽略的点。第一,logout过滤器不是写一个/logoutURL 就能用的,它默认会 POST 请求才能触发(新版本对 get 做了限制),前端需要正确发起请求。第二,如果把/logout放在/** = authc后面,那用户未登录时就没法调用退出接口,因为它先被拦截了,这也是一个容易踩的坑。
4.3 注解鉴权不生效
我在 3.4 节提过DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor,这里再展开说一次。如果是 Spring Boot 项目,用shiro-spring-boot-starter时这两个 Bean 的自动配置容易失效,尤其是项目里自定义了多个切面时,可能导致 Shiro 的切面排在后面根本没机会执行。排查方法很简单:在 Controller 方法上加个@RequiresPermissions("a:b"),然后请求一个完全没有权限的接口,看是返回 500 还是直接放行。如果直接放行,说明切面没生效。
还有一个更隐蔽的问题:注解放在私有方法上不生效,放在同一个类内部调用的场景也会失效(AOP 代理没有经过外部调用路径)。这些问题看起来像是 Shiro 的毛病,实际是 Spring AOP 的通用限制,但因为它发生在 Shiro 注解上,很多人对 Shiro 的“陌生感”就是这么来的。
4.4 前后端分离下的登录失效与重定向
传统 Shiro 在 Web 场景下默认未登录会重定向到登录页,这是给服务端渲染页面设计的。前后端分离架构中,你希望的是返回 401 JSON,而不是 302 让前端到处跳转。做法是自定义UserFilter,覆盖onAccessDenied:
public class CorsUserFilter extends UserFilter { @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或会话已过期\"}"); return false; } }然后把authc改成自定义的 filter。这个坑几乎每个前后端分离项目都会遇到。从“精通”到“陌生”的过程中,很多人就是因为在改完前后端分离后,发现登录跳转就跟鬼一样不受控。
4.5 多节点部署的会话共享
多节点部署时,如果每个节点的 Shiro 会话都各自存本地内存,用户请求打到不同节点就会反复要求登录。解决办法有两个:一是用 Redis 统一存 Session;二是做粘性会话(sticky session),但后者的容错性很差,非常不推荐。用 Redis 时,注意序列化方式要统一,不要出现一个节点写入的对象另一个节点反序列化失败。另外,CookieRememberMeManager里的rememberMe在分布式环境下也要注意,它的 cookie 值基于 JVM 默认密钥序列化,多节点必须保证密钥一致,否则一个节点签发的 rememberMe cookie 到另一个节点会解密失败。
5. 从“陌生”回到“精通”:保持熟练度的方法论
5.1 从配置型选手变成原理型选手
我见过很多“精通 Shiro”的简历,实际上没有一行是自己设计方案的结果,全是套模板。想要真正不陌生,必须在脑子里形成一条完整的链路图:请求进来,被过滤器拦截,创建 Subject,绑定到 ThreadContext,进入 Realm 取身份信息,Matcher 比对密码,授权时查角色权限,结果走缓存,再把当前用户包装进 Session。这条链路的任何一环出问题,你都得能快速推理出来。所以我建议你花一个下午,单纯用 Main 方法去调用 Shiro 的核心 API,不接 Web、不接 Spring,自己走一遍DefaultSecurityManager的认证授权流程。做完这个实验,你对 Shiro 的理解会明显不一样。
5.2 维护一份自己的速查清单
作为一线开发,我习惯把常用的配置、易错点、异常类型、排查命令写进自己的速查文档。Shiro 这边我维护了一份清单,内容包括:认证异常和对应的处理场景、过滤器链的常用组合、AuthorizationInfo的权限字符串格式、SessionDAO实现时的必要重写方法、CredentialsMatcher的盐值处理。这些内容不需要背,但每次遇到问题时,能在五分钟内定位到是哪一类,效率会高很多。
5.3 何时该用 Shiro,何时该换方案
承认 Shiro 有它的边界也是一种“精通”。如果是简单的单体后台管理系统,Shiro 非常合适,轻量、直观、侵入性低。但如果你是一个大型微服务系统,每个服务都需要鉴权,并且团队已经深度使用 Spring Boot 和 Spring Security 体系,那强行引入 Shiro 反而会增加维护成本。还有一个现实考量:Shiro 的社区活跃度相比 Spring Security 低不少,遇到新框架(比如 Spring Boot 3.2 以上)的兼容问题,能查到的资料有限。所以我现在的选型原则是:已有的老系统,继续用 Shiro 但把核心链路吃透;新系统如果还没定安全框架,先把需求搞清楚,Spring Security 重度项目就别硬切 Shiro。
5.4 保持熟练度的几个实操建议
最后分享几个我自己实践下来还不错的保持熟练度的方法。第一,每两个月把自定义 Realm 手写一遍,不复制。第二,在一个测试项目里故意制造几个常见 bug,比如密码盐值不一致、过滤器顺序颠倒、注解不生效,然后自己排查。第三,抽时间把 Shiro 官方关于Realm和Subject的接口源码翻一遍,不要只看文档,文档帮不了你理解ThreadContext和DefaultSessionManager的内在逻辑。通过这些简单但重复的练习,你会在下一次面对权限问题时,眼都不眨地说出:“这个报错,应该是 Realm 那个盐值问题。”
坦率地讲,我从一个“配置全靠抄”的初学者,到后来能自己设计一套多 Realm 的权限体系,中间翻车次数非常多。但正是那些“为什么登录不过”“为什么接口没权限”的深夜,把我重新拉回到对 Shiro 原理的钻研上来。技术这东西,你停手一天,它就离你陌生一点;你每隔一阵子亲手把底层流程写一遍、踩一遍、修一遍,它才会真正变成你的东西。Shiro 如此,其他框架也一样。