最近帮一家做企业系统的公司处理线上权限问题代码用的是 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 交给AuthenticatorAuthenticator找到匹配的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 groupIdorg.apache.shiro/groupId artifactIdshiro-spring/artifactId version1.13.0/version /dependency dependency groupIdorg.apache.shiro/groupId artifactIdshiro-core/artifactId version1.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 的完整实现下面是一份可以直接拿来改的UserRealmpublic 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(); SetString roles userService.listRoles(userId); SetString 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); } }然后设置SecurityManagerBean 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 放在 Redisvalue 通过 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覆盖onAccessDeniedpublic class CorsUserFilter extends UserFilter { Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException { response.setContentType(application/json;charsetUTF-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 如此其他框架也一样。