
有人说Shiro简单我当年也是这么觉得的。接触Shiro的第一周我就能把过滤器链配得飞起登录、退出、权限注解一把梭觉得自己已经“精通”了这套安全框架。直到一个周三下午线上系统出了个诡异问题一个用户明明拥有角色权限却一直被打回登录页另一个用户被强制踢下线旧会话却还能继续调接口。我顺着调用链开始读源码查缓存、查会话、查过滤器顺序越查越觉得自己其实一直处于“陌生”状态——我熟悉的只是演示工程里的那几条配置而不是Shiro真正的工作方式。这篇文章就是一次从“精通”到“陌生”的完整复盘我会把排查思路、底层机制和修复方案都摊开讲适合所有在Java项目里用过Shiro、或者正准备接手Shiro项目的同学。1. 过滤器链看起来太简单导致很多人在第一层就埋雷1.1 配置一个URL权限不是“从上往下写”就完事很多教程都会甩出这么一段配置Bean public ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition definition new DefaultShiroFilterChainDefinition(); definition.addPathDefinition(/login, anon); definition.addPathDefinition(/admin/**, authc, roles[admin]); definition.addPathDefinition(/user/**, authc, perms[user:view]); definition.addPathDefinition(/**, authc); return definition; }这段配置看着没什么问题日常Demo也能跑通但实际项目里很容易写出这种顺序definition.addPathDefinition(/**, authc); definition.addPathDefinition(/login, anon);Shiro的过滤器链匹配是从上到下逐个比较、命中即停的。把/**放在最前面所有请求都会被当成需要登录处理后面的/login anon根本没有机会生效。于是用户访问登录页也会被重定向到登录页形成肉眼看不见的死循环。我在某项目里遇到过比这更隐蔽的版本/admin/login配置了anon但/admin/**配置在它前面还带了roles[admin]。运维同学访问登录地址直接403因为URL先被/admin/**接住而这时候Subject还没登录自然也没有admin角色。这里的关键认知是Ant风格匹配不是“越长越优先”而是“先配先匹配”/**是贪婪匹配撞上就会短路通配符配置顺序本身就是权限规则的一部分不是随手的装饰想验证某条URL到底命中了哪个filter最直接的办法是打开Shiro的DEBUG日志看FilterChainResolver给出的实际过滤器列表。日志里会打印当前请求经过的每个filter只要看到/login对应的链条里出现了authc而不是anon就说明顺序已经被写坏了。1.2 anon、authc、user三个过滤器平时用着没差别出事才知道各有脾气Shiro内置过滤器里最容易混淆的就是anon、authc和user。anon表示匿名可访问不做任何认证判断authc要求用户必须通过表单或token方式完成认证user则更宽容——它允许用户通过RememberMe记住登录状态访问。文字上看很清晰但实际项目的坑在于很多同学把首页、接口和静态资源都配成authc一旦用户的会话过期页面里的静态样式全部失效因为静态资源也被拦截下来了。我见过不止一次因为静态资源被authc拦截导致登录页渲染得乱七八糟的案例。user和authc的取舍更值得说。user过滤器配合RememberMe可以做到“用户关闭浏览器再打开仍然能访问需要登录的页面”但前提是rememberMe功能被正确开启。如果业务上不允许“记住我”之后直接进入敏感页面那user就不该出现否则就相当于忘了把门锁上。definition.addPathDefinition(/order/list, user); definition.addPathDefinition(/order/detail, authc);这种混合配置看起来也没问题但业务语义要统一/order/list是普通页面可以记住登录状态访问/order/detail是敏感数据必须每次会话内重新认证。要是两者放反了敏感订单详情就能用rememberMe长驱直入安全审计的时候根本解释不清。2. Realm是这个框架的命根子Java方法写对了才谈得上精通2.1 认证方法里有个常见动作把异常吞掉Shiro的认证核心在Realm的doGetAuthenticationInfo方法新手版本通常是这样的Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username (String) token.getPrincipal(); User user userMapper.selectByUsername(username); if (user null) { throw new UnknownAccountException(用户不存在); } return new SimpleAuthenticationInfo(user, user.getPassword(), getName()); }看起来没毛病但实际项目里经常出现两种改法第一种在方法里try-catch吞掉了所有异常只返回一个null。Shiro对返回null的默认处理是抛出UnknownAccountException表面看还能正常工作但真正的用户不存在和密码错误就再也区分不开了后续做“账号锁定策略”会非常痛苦。第二种把数据库异常和认证异常混在一起。比如查用户时数据库连接超时被catch后包装成AuthenticationException前端拿到的是“用户名或密码错误”实际上系统已经出了故障用户只能干瞪眼。我的习惯是doGetAuthenticationInfo里只处理业务判断和密码校验数据库异常、缓存异常直接向外抛不在这里包装。Shiro本身有一套清晰的异常体系UnknownAccountException、IncorrectCredentialsException、LockedAccountException这些足够表达绝大多数登录场景。Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username (String) token.getPrincipal(); User user userMapper.selectByUsername(username); if (user null) { throw new UnknownAccountException(用户不存在); } if (user.getStatus() ! UserStatus.ACTIVE) { throw new LockedAccountException(账号被锁定); } return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName()); }2.2 加盐密码不是“多存一个字段”那么简单密码存储这一块Shiro提供的SimpleHash工具非常方便但方便也容易让人误解。String hashPassword new SimpleHash(MD5, rawPassword, salt, 2).toHex();很多项目直接用这个工具生成密码觉得加了盐和迭代次数就安全了。但从我审过的老项目来看最大的问题不是加密算法而是盐的获取方式不一致。有的地方用用户名当盐有的地方用UUID当盐还有的地方干脆把盐固定死。这里最关键的一个设计盐必须每个用户独立而且doGetAuthenticationInfo返回的盐要和生成密码时使用的盐完全一致。我用UUID做盐存进用户表然后返回ByteSource.Util.bytes(user.getSalt())这样Shiro在校验时会自动把输入的密码做同样处理再比较。踩过的一个真实坑某个老项目升级时把密码从MD5换成了SHA-256加盐但数据库里已有的密码还是旧算法生成的。为了兼容我在Realm里做了“新旧算法分版本判断”的逻辑——先用新算法尝试校验失败再判断密码字段前缀。这种代码能跑但非常丑陋后来我彻底改成“登录成功时检测旧算法并自动升级存储”才把这个债还掉。安全建议只有三条不要用明文MD5至少加独立随机盐迭代次数根据硬件适当提高不要只迭代2次密码字段和盐字段分开存储不要拼接成一个字符串2.3 doGetAuthorizationInfo越写越复杂说明权限模型已经该动了doGetAuthorizationInfo是每个请求都可能触发的授权方法里面通常要查角色、查权限。问题是很多项目在最初没有做权限模型规划导致这个方法里堆了一堆SQL查询、缓存穿插、甚至远程调用。我见过一个极端案例某个内部管理系统用户只有几十个但doGetAuthorizationInfo里每查一次权限要join五张表再走一次RedisRPC还调用另一个系统判断组织层级单次授权逻辑耗时超过200毫秒。看起来不多但是每次请求只要Shiro缓存没命中就要重复这一套接口整体延迟被拖得很明显。更好的做法是把“角色”和“权限”分开建模用户表id、用户名、密码、盐角色表id、角色编码用户角色关系表权限表id、权限标识角色权限关系表doGetAuthorizationInfo里的逻辑就变成两个查询一个查角色一个查权限。角色和权限都用SetString返回Shiro内部的WildcardPermission再去匹配user:view这种表达式。Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { User user (User) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); SetString roles roleMapper.selectRoleCodesByUserId(user.getId()); SetString perms permissionMapper.selectPermsByUserId(user.getId()); info.setRoles(roles); info.setStringPermissions(perms); return info; }如果这个查询仍然很慢那就不是Realm的问题是权限模型本身要重构了。别在Realm里通过复杂分支硬撑撑到最后就是一个谁也看不懂的“大泥球”。3. 权限注解“时灵时不灵”我把代理和缓存链路重新查了一遍3.1 RequiresPermissions不生效多数不是注解写错是bean没被代理Shiro的权限注解走的是Spring AOPAuthorizationAttributeSourceAdvisor会把RequiresPermissions这些注解翻译成一套权限检查逻辑。如果这个Advisor没有被注册成Spring Bean或者类的代理方式不对注解就是摆设。我在某项目里遇到过一个非常典型的现象普通RestController上的权限注解不生效但Service层的方法上生效。排查到最后发现Controller类没有实现任何接口而Spring的默认代理配置是JDK动态代理它只能代理接口方法。Shiro的注解织入到Controller上时因为目标类不是接口实现代理没建立起来注解自然失效。解决方式有几种最省事的是在配置类上加SpringBootApplication EnableAspectJAutoProxy(proxyTargetClass true) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }proxyTargetClass true会强制走CGLIB类代理Controller和Service都能统一处理。但要注意CGLIB代理有它自己的限制类不能是final的方法不能是final的。如果项目里恰好有个用了final修饰的Controller方法那你就会从“注解不生效”变成“启动报错”问题反而更容易定位。另一种更隐蔽的情况是方法内部自调用。比如public void createOrder() { this.cancelOrder(); // 自调用不走代理 } RequiresPermissions(order:cancel) public void cancelOrder() { }这时候cancelOrder()的权限注解不会生效因为this调用没有经过代理对象。整改方式是把跨方法调用改成注入自身代理对象或者把需要校验权限的方法放到另一个Service里让代理链路完整。3.2 权限缓存让“改权限后马上生效”变成一句空话Shiro默认每次授权都调Realm但如果配了CacheManagerAuthorizationInfo会被缓存住。这时候就出现了一个新的矛盾管理员改了用户的角色用户刷新页面却仍然看到旧权限。我在给某管理后台排查时亲眼看见用户在权限变更二十分钟后仍然能访问旧页面。原因就是Shiro的授权缓存是缓存了整个人物的权限集合权限变更并没有触发缓存清理。想要权限变更后尽快生效有两条路不缓存授权信息权限每次实时查询。适合用户量少、权限表也不大的内部系统。缓存授权信息但在用户角色/权限变更时主动调用realm.clearCache(principals)清理对应缓存。第二种方式的通用写法是public void updateUserRoles(Long userId, ListLong roleIds) { userRoleService.updateUserRoles(userId, roleIds); User user userMapper.selectById(userId); Subject subject SecurityUtils.getSubject(); if (subject.getPrincipal() ! null) { realm.clearCache(subject.getPrincipals()); } }这里有个细节如果是管理员在后台修改别人的权限SecurityUtils.getSubject()拿到的是管理员的Subject清理的是管理员的缓存和真正要清理的用户对不上。正确做法是根据用户标识构造一个SimplePrincipalCollection再调realm.clearCache()。否则就会出现“改完权限后自己旧权限消失了目标用户权限却一直没变”的怪事。缓存这把双刃剑配置起来十行代码出问题的时候藏得很深。我后来在每个Realm里都预留了clearCacheByUserId方法专门给业务侧调用省得每次都在表层绕。4. 会话管理并发登录、踢人和“明明过期了还能访问”的怪事4.1 先分清楚Shiro的会话和HttpSession是不是一回事Shiro把会话抽象成自己的Session体系DefaultWebSecurityManager默认会绑定到Servlet容器的Session所以多数场景下它就是HttpSession的一层封装。但在配置了DefaultWebSessionManager之后Shiro会通过SessionDAO管理会话这时它可能不再是Servlet容器Session了。这两者的差异直接影响很多东西HttpSession的过期由容器管理Shiro管不到太细Shiro自己的SessionManager可以在会话创建、过期、停止时执行自定义策略sessionId的传递方式可以是cookie也可以放在URL里某老项目里出现过“用户访问后Session ID暴露在地址栏”的问题就是因为配置了会话管理后sessionIdUrlRewriting被打开了。浏览器地址栏带上;JSESSIONIDxxx不仅丑还容易被分享链接把会话泄露出去。这个只要在配置里关掉就行sessionManager.setSessionIdUrlRewritingEnabled(false);4.2 并发登录踢人的常规实现很容易在“同一个用户多端登录”上踩坑很多管理系统的需求是同一个账号只允许一台设备登录新登录会把旧会话踢下线。Shiro没有现成的“踢人”开关常见做法是自定义SessionDAO在创建会话时查同一个用户的旧会话。伪代码大概是public class KickoutSessionDAO extends EnterpriseCacheSessionDAO { Override protected Serializable doCreate(Session session) { // 根据 session 中的 userId 查询旧会话 // 删除或停止旧会话 return super.doCreate(session); } }这里真正麻烦的不是删除旧会话而是“多端登录”怎么定义。同一个用户用手机和电脑同时登录算不算并发用户打开两个浏览器标签页也算并发吗如果不小心把同一浏览器的多个标签页当成多个会话踢来踢去用户会在操作的时候被反复登出体验极差。我见过一套相对合理的方案用户表里加一个login_device_type字段移动端和PC端分开计数同一个设备类型只允许一个会话但跨设备类型可以共存。会话里记录设备类型和userId踢人的时候只踢同类新登录。虽然代码多了一点但基本不会误杀。4.3 “会话明明过期了为什么还能访问”通常和rememberMe无关会话过期的判断有两条链路一是Shiro的SessionManager在获取会话时检查lastAccessTime和timeout二是底层容器自身的Session过期时间。只要链路中任何一处没有正确生效都会出现“过期了还能用”的错觉。其中一个容易忽略的点是globalSessionTimeout配置。Shiro默认全局会话超时是30分钟但如果你自己的应用在web.xml里配置了session-timeout为120分钟最终生效的是哪一个要看SessionManager和容器会话接管的程度。我在调试某系统时明明配置了Shiro的timeout但用户实测一个小时后仍处于登录状态后来发现是容器的session-timeout把Shiro的过期时间覆盖了。“过期了还能访问”还有一种常见解释filter链上用的是user而不是authc。rememberMe的会话即使原始Session过期只要rememberMe cookie还在用户仍然能以“已记住”状态访问配置了user的路径。如果业务不允许这里必须用authc严格卡住。最终我处理这类问题时会在过滤器链里先给关键接口加明确规则再在会话监听器里打日志看每个请求的session真实创建和最后访问时间基本几分钟就能定位。5. 真要“从精通变陌生”往往是升级和迁移时才刚开始5.1 Shiro 1.x老项目强行升级到新Spring Boot槽点全部集中爆发Shiro本身是Java社区的老牌框架很多人项目里用的还是1.x版本配合Spring Boot 2.x还能勉强运行。可一旦技术栈整体往Spring Boot 3/Spring Framework 6上迁移问题就接踵而至Jakarta包名变更、javax迁移、Spring的自动配置机制变化叠加Shiro 1.x老版本不兼容新API编译期就能报一大堆错。我处理过的某内部系统就卡在这一步升级Spring Boot 3后Shiro 1.8.x在初始化过滤器时抛异常因为里面大量反射调用了旧的javax.servlet类。当时想继续用Shiro被迫调整了一版又臭又长的兼容代码两边互相妥协代码可读性下降得很厉害。后来等Shiro 2.x/对应的演进版本相对稳定后才算真正解套。如果你是老项目的维护者接手时第一时间要做的事不是改功能而是查看项目里的Shiro版本、Spring Boot版本、用的filter链、SessionDAO实现。这些组合越乱迁移成本越高趁早规划比临阵磨枪强。5.2 把会话和权限缓存放进Redis序列化问题是最容易炸的雷很多高并发的项目不愿把会话存在单机内存里想用Redis做会话共享和权限缓存。理论上没问题实操中第一个遇到的坑就是序列化。Shiro里存进Session的对象五花八门用户实体、权限集合、临时业务对象。如果这些对象没有实现Serializable存Redis时直接抛NotSerializableException即使实现了序列化Redis里存的是一串反人类字节排查问题的时候根本不知道里面是什么。某项目排查“用户会话串号”问题时最后发现是RedisTemplate的序列化器和SessionDAO使用的序列化器不一致导致数据错乱。说得直白一点写入时用一个key规则读取时用另一个key规则或者同一个key却被两种序列化器交替读写就会出现A的会话被B的请求读到。我的建议是会话里不要塞整个User实体尽量只放userId和必要的标识明确统一的序列化方式要么都JSON、要么都Java序列化不要混用Redis中Session的key不要用裸的sessionId加前缀如shiro:session:xxx避免和业务缓存冲突5.3 老Shiro项目的安全漏洞不是“不升级就没事”Shiro的历史版本里出过一些比较扎眼的安全问题尤其是反序列化相关的攻击面。旧版本确实“能跑”但如果暴露在不可信网络环境下风险等级根本不是业务能不能跑的问题而是系统会不会直接被打穿的问题。有段时间我接手一个老项目第一件事是拉出所有依赖树看看Shiro是否还停留在很久以前的版本。确认之后的做法很直接在正式业务改造前先跨版本升级到修复了已知问题的版本同时把那些不必要的反序列化入口收掉保证上课的组件没有明明可以修却不修的烂债。这里需要强调一点升级Shiro版本并不是只换一个jar包路径就行。有些配置项在旧版是默认关闭的新版可能改了默认行为自定义Realm的接口也可能有细微变化。升级后必须把“登录、退出、权限判定、rememberMe、并发会话”这条主链路完整回归一遍不能只跑登录就宣告完成。6. 项目里继续用Shiro还是赶“潮流”我心里那杆秤长这样6.1 什么项目继续用Shiro更划算Shiro的优势是轻量和直观。如果你的项目满足这些条件继续用它一点问题都没有团队规模不大安全需求集中在登录认证和URL/方法级权限不需要复杂的OAuth/OIDC等第三方登录协议集成需求变化不频繁权限模型稳定现有代码里Shiro的链路已经跑了两三年没有明显坑我见过不少用了Shiro好几年的项目维护得非常好代码里过滤器链清楚Realm逻辑精简会话策略明确。这种项目完全没必要为了“换框架”而换框架技术选型要看团队维护成本和当前系统的稳定性不是看热搜词。如果非要提一句对比Spring Security在功能完整度和生态上确实更强势但这个优势对只有两三个管理员的小系统来说根本用不上反而会因为配置繁琐、过滤器链复杂而把简单事搞复杂。6.2 决定迁移前先给现有权限体系做一次“体检”严格来说“迁移到其他安全框架”不是技术问题而是业务和代码成本问题。Shiro和Spring Security对权限模型、会话策略、过滤器链的抽象完全不一样迁移不是改几个注解就能完成的事。我建议把这几项先盘清楚权限项总数量几百个和几万个的迁移工作量完全不同菜单、按钮、接口三层权限是怎么匹配的Shiro用通配符权限新框架用Authority映射逻辑要先定会话策略并发登录限制、会话过期时间、rememberMe的语义自定义过滤器登录页跳转路径、未授权处理、验证码校验、登录失败逻辑体检的产出是一张权限规则映射表列出“Shiro当前规则”和“新框架等价规则”。没有这张表就开始迁移基本就是边改代码边翻车。6.3 如果已经决定迁移我建议走的路径真正稳妥的迁移路径不是“一个周末重写登录”而是并行过渡第一步先重构业务层让业务代码不直接依赖Shiro的Subject和Session获取用户信息统一走自定义的CurrentUserHolder。第二步把登录入口做成可切换Shiro过滤器先拦老逻辑新逻辑走新的安全处理器通过开关控制放行比例。第三步把Shiro的过滤器链一条一条替换成新框架对应的过滤链路每替换一个就回归一批接口。第四步全部替换后清理Shiro的依赖和残留配置再做一轮完整安全回归。这条路听起来慢但技术债本来就是靠时间滚出来的一次产线切换比什么都快。我在实际项目中更倾向于一个原则如果Shiro链路已经稳定运行且代码清晰优先考虑继续维护如果因为升级、漏洞、并发会话复杂度过高而被迫迁移那就像上面的步骤一样把迁移当成正式项目来做而不是随手一改。最后分享一个我自己的排查习惯出了问题先不急着改配置先打开Shiro和Spring的DEBUG日志把“这个请求经过了哪些过滤器、进入了哪个Realm、权限缓存有没有命中、会话ID是什么”这四个问题回答完再动手。大多数说“Shiro难搞”的情况都是在这四步之后才发现原来只是自己对它还不够熟悉。