有人说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五张表再走一次Redis,RPC还调用另一个系统判断组织层级,单次授权逻辑耗时超过200毫秒。看起来不多,但是每次请求只要Shiro缓存没命中,就要重复这一套,接口整体延迟被拖得很明显。
更好的做法是把“角色”和“权限”分开建模:
- 用户表:id、用户名、密码、盐
- 角色表:id、角色编码
- 用户角色关系表
- 权限表:id、权限标识
- 角色权限关系表
doGetAuthorizationInfo里的逻辑就变成两个查询:一个查角色,一个查权限。角色和权限都用Set<String>返回,Shiro内部的WildcardPermission再去匹配user:view这种表达式。
@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { User user = (User) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); Set<String> roles = roleMapper.selectRoleCodesByUserId(user.getId()); Set<String> perms = permissionMapper.selectPermsByUserId(user.getId()); info.setRoles(roles); info.setStringPermissions(perms); return info; }如果这个查询仍然很慢,那就不是Realm的问题,是权限模型本身要重构了。别在Realm里通过复杂分支硬撑,撑到最后就是一个谁也看不懂的“大泥球”。
3. 权限注解“时灵时不灵”,我把代理和缓存链路重新查了一遍
3.1 @RequiresPermissions不生效,多数不是注解写错,是bean没被代理
Shiro的权限注解走的是Spring AOP,AuthorizationAttributeSourceAdvisor会把@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,但如果配了CacheManager,AuthorizationInfo会被缓存住。这时候就出现了一个新的矛盾:管理员改了用户的角色,用户刷新页面却仍然看到旧权限。
我在给某管理后台排查时,亲眼看见用户在权限变更二十分钟后仍然能访问旧页面。原因就是Shiro的授权缓存是缓存了整个人物的权限集合,权限变更并没有触发缓存清理。
想要权限变更后尽快生效,有两条路:
- 不缓存授权信息,权限每次实时查询。适合用户量少、权限表也不大的内部系统。
- 缓存授权信息,但在用户角色/权限变更时主动调用
realm.clearCache(principals)清理对应缓存。
第二种方式的通用写法是:
public void updateUserRoles(Long userId, List<Long> 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被打开了。浏览器地址栏带上;JSESSIONID=xxx,不仅丑,还容易被分享链接把会话泄露出去。这个只要在配置里关掉就行:
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难搞”的情况,都是在这四步之后才发现原来只是自己对它还不够熟悉。