聊到Java后端的安全框架,Shiro绝对是一个绕不开的名字。我第一次接触它是在一个后台管理系统的权限改造里,业务方只提了一个需求:实现登录认证和菜单权限控制。当时我在Spring Security和Shiro之间犹豫,最后敲定了Shiro,原因很朴素——它的学习曲线更平缓,文档虽然旧但案例足够多,代码侵入性小,而且认证、授权、会话管理、加密这几件安全领域最常见的杂事,它打包得特别干净。
Shiro本质上是Java生态里一个轻量级的安全框架,解决的核心问题是“你是谁、你能干什么、你的状态怎么维持”。它不像某些安全方案那样强行改变项目结构,而是通过几个核心组件把安全逻辑从业务代码里抽离出来,让开发人员不用在每一个接口里手写校验逻辑。这篇文章不打算泛泛罗列概念,我会从项目定位、核心架构、认证授权、Realm实践、加密处理、会话管理、Spring Boot整合到问题排查这几条线完整走一遍,重点说清楚每个环节背后的设计原因和实操中会踩的坑。
适合谁来读?如果你是Java后端开发,刚接触Shiro,或者已经用过一段时间但被各种配置文件搞得很晕,这篇内容可以帮你把零散的知识点串成一条线。即使你之前只做过简单的登录功能,也可以照着后面的代码配置把手里的项目跑起来。
1. 项目定位与选型评估:为什么不少项目最终选了Shiro
1.1 Shiro到底解决了什么问题
很多新人第一次看到Shiro,容易把它和“登录拦截器”画等号。实际上Shiro的价值远不止拦截未登录请求那么简单。平时在后端项目里,安全相关的需求通常有五个方面:登录认证、权限校验、会话跟踪、数据加密和潜在的并发登录控制。如果每个接口都自己写一套校验逻辑,代码会迅速腐化,到处都是重复的判断和散落的权限字符串。Shiro把这些统一抽象成一套机制,开发人员只需要声明“这个路径需要什么权限”或者“这个方法需要哪个角色”,剩下的事框架来处理。
具体来说,Shiro提供了四个核心能力域。第一个是认证,负责验证用户的身份,比如用户名的密码是否正确、验证码是否匹配;第二个是授权,负责判断当前用户有没有权限执行某个操作;第三个是会话管理,它不依赖Servlet容器,可以独立管理用户会话,甚至支持把会话数据放到Redis里实现多节点共享;第四个是加密,提供了一套封装好的哈希、加盐工具。这四个能力结合起来,基本覆盖了一个中小型后台系统90%的安全需求。
1.2 和Spring Security相比如何取舍
这是我在写后台系统时经常被问到的问题。从功能完整度来说,Spring Security确实更强大,和Spring生态的整合也更紧密,在复杂的企业级权限模型、OAuth2.0、CAS单点登录等场景下表现更好。但Spring Security的学习曲线明显更陡峭,它内部有一套复杂的过滤器链和大量的配置对象,新人经常会陷入“不知道哪个过滤器拦截了请求”的迷茫。
Shiro的优势在于概念模型简单,核心组件只有几个,源码读起来也轻松。它的Configuration可以集中在几个Bean里,过滤链规则直白,权限注解和JSP标签都很好理解。对于大多数内部管理系统、后台运营平台、中小型单体应用来说,Shiro完全够用,而且能让团队快速上手。如果项目本身是微服务架构、大量使用Spring Cloud Gateway或需要深度的OAuth2集成,那我会建议优先考虑Spring Security。
1.3 适用场景与选型建议
从我接触过的项目来看,Shiro最舒服的场景是传统单体后台、带管理端的前后端一体项目,以及一些对权限模型要求不复杂的互联网应用。凡是“登录后访问受限资源、不同角色看不同菜单、接口按照权限字符串控制”这一类需求,Shiro都能快速落地。
不太适合的场景是:需要高度定制化的安全协议、需要与第三方IdP做复杂联合登录、或者你的团队本来就非常熟悉Spring Security。选型没有绝对的谁比谁好,关键是看团队的学习成本和项目的实际复杂度。Shiro最大的优势是“够用且简单”,你不需要为了一个简单的登录功能引入一套庞大的安全基础设施。
2. 核心架构与认证授权原理
2.1 认准这三个核心组件就够了
Shiro的架构看起来有一堆术语,但真正需要深入理解的只有三个组件:Subject、SecurityManager和Realm。
Subject是“当前用户”的抽象。这个用户不一定是自然人,也可能是第三方进程、定时任务等。在代码里,我们通过SecurityUtils.getSubject()拿到当前请求对应的Subject对象,然后调用它的login()方法、isPermitted()方法、getSession()方法等。可以把Subject看作是一张门禁卡,它代表当前操作者的身份和权限状态,但卡片本身不负责校验,它只是把请求转交给背后的管理者。
SecurityManager是Shiro的心脏,它负责协调所有安全操作。Subject的所有委托最终都汇聚到SecurityManager里。它管着多个子组件,比如Authenticator负责认证、Authorizer负责授权、SessionManager负责会话。实际开发中我们很少直接操作SecurityManager,但它必须在Spring配置里被注册,否则整个安全体系转不起来。
Realm是我的理解里最关键的组件,它是一层“数据桥”,Shiro并不关心你的用户数据放在数据库、LDAP还是配置文件里,Realm就是专门干这个的。认证时它负责根据用户名查到密码和相关凭证,授权时它负责查出当前用户的角色和权限列表。写Shiro项目时,大部分开发工作量其实都在写自定义Realm。
2.2 认证流程拆解:从Token到AuthenticationInfo
Shiro的认证流程可以用“提证、核实、放行”三个阶段来理解。用户提交账号密码后,Controller里会创建一个UsernamePasswordToken,把账号和密码放进去。然后调用subject.login(token)。这一步只是个开始,Token会经过SecurityManager被转交给内部的Authenticator,Authenticator再根据配置找到对应的Realm,把Token丢给Realm。
Realm收到Token后,第一件事是从Token里取出身份信息,比如用户名。接着查数据库或缓存,拿到这个用户真正的密码、盐值等数据。这里有一个容易踩的坑:如果查不到用户,Realm应该返回null而不是抛异常,Shiro会把null理解为“查无此人”,然后抛出UnknownAccountException。如果用户被锁定或禁用,可以抛LockedAccountException。查到了之后,Realm需要把这些数据封装成一个AuthenticationInfo对象,里面至少包含身份标识和凭据。
等Realm返回了AuthenticationInfo,真正的密码校验才开始。Shiro使用CredentialsMatcher来比对用户提交的明文密码和系统中的凭证。如果配置了加盐哈希,这一步会先把用户提交的密码用同样的盐值、同样的算法和迭代次数计算一遍,再和库里存的值做比对。比对通过,subject.login()返回,Subject被标记为已认证状态;比对失败,抛出IncorrectCredentialsException。整个流程下来,业务代码里不用写任何密码比较的逻辑,全部交给框架处理。
2.3 授权机制拆解:角色、权限与通配符
授权往往比认证更容易让人困惑,因为很多项目把角色和权限混为一谈。我习惯用一个比喻来解释:角色是职位头衔,权限是具体动作。比如“管理员”是角色,“删除用户”是权限。Shiro同时支持基于角色和基于权限的两种判断方式,但实际开发里我更推荐基于权限判断,因为权限字符串的粒度更细,也更灵活。
Shiro里最常用的权限字符串用冒号分隔,基本格式是“资源:操作”,例如user:view表示查看用户,user:create表示创建用户,user:*表示对用户的所有操作。这样写的好处是权限粒度可以动态调整,不用为了一个操作去改代码。有两个判断方法要区分:hasRole("admin")是判断角色,isPermitted("user:delete")是判断权限。注意hasRole是全等判断,用户必须有指定的角色才返回true,而权限字符串支持通配符和部分匹配。
授权信息的获取由Realm的doGetAuthorizationInfo方法完成。这个方法会在需要判断权限时被触发,它返回一个SimpleAuthorizationInfo,里面可以塞入角色集合和权限字符串集合。这里有个性能隐患:默认情况下Shiro每做一次权限判断都可能调用Realm去查库,所以实际项目中一定要配置授权缓存,让权限数据在内存里缓存起来,否则高并发下数据库压力会非常大。
3. Realm实践与密码加密的正确姿势
3.1 Realm的设计思路:安全框架与业务数据的桥梁
很多人在第一个Shiro项目里就一个Realm走到底,这本身没错,但理解Realm的设计思路会帮你更好地应对复杂场景。Realm的核心职责是“把业务数据翻译成Shiro能理解的安全数据”。业务数据是用户名、密码字段、状态标记;安全数据是AuthenticationInfo和AuthorizationInfo。翻译工作全部在Realm内部完成。
继承AuthorizingRealm是最常见的做法,因为AuthorizingRealm同时实现了认证和授权两个接口,一套类就能搞定。AuthorizingRealm本身还内置了凭据匹配器,默认会通过简单的equals比较提交密码和存储密码。我们通常需要配置一个HashedCredentialsMatcher来支持哈希加盐的密码校验。如果项目里密码存储方式比较特殊,比如需要通过另一个服务远程校验,也可以重写doCredentialsMatch方法,直接把匹配逻辑替换掉。
我实际做项目时还会做一个小小的设计:在Realm里不直接写数据库查询语句,而是调用Service层。这样做的好处是,权限数据变化的时可以在Service层做缓存处理,避免Realm和持久层耦合。如果你的项目里有多种用户类型,比如后台管理员和前台用户,可以考虑配置多个Realm,并在ModularRealmAuthenticator里指定各自的认证策略,不过对大多数后台系统来说,一个Realm足够。
3.2 自定义Realm:从零写一个AuthorizingRealm
直接看代码最直观。下面是一个典型的自定义Realm,在模拟项目X里我按这个模板改一改就能跑通。先看认证部分:
public class MyRealm extends AuthorizingRealm { private UserService userService; @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username = (String) token.getPrincipal(); User user = userService.findByUsername(username); if (user == null) { return null; } if (user.getStatus() == 0) { throw new LockedAccountException("账号已被锁定"); } return new SimpleAuthenticationInfo( username, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName()); } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); Set<String> roles = userService.findRolesByUsername(username); Set<String> permissions = userService.findPermissionsByUsername(username); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setRoles(roles); info.setStringPermissions(permissions); return info; } }两个方法对应两条链路:doGetAuthenticationInfo负责“取数据”,返回AuthenticationInfo之后Shiro的CredentialsMatcher会自动比对密码;doGetAuthorizationInfo负责“取权限”,在权限判断时被触发。有几个细节值得注意:认证返回的SimpleAuthenticationInfo构造函数里传入了getName(),这是Realm的名字,如果是多Realm场景它会成为区分依据;密码字段我们放的是哈希后的值,而不是明文,这是绝不能妥协的安全底线。
3.3 密码加盐:存储规范与校验过程
密码存储是安全框架里最容易被小看的部分。早期很多系统直接用MD5做哈希,还洋洋得意,实际上现在的计算能力下,裸哈希非常容易被彩虹表破解。Shiro从设计上就支持加盐哈希,这是让我对它加分的地方。
加盐的思路不复杂:每个用户生成一个随机的盐值字符串,把“原始密码 + 盐”一起做哈希运算,重复很多次,然后把哈希结果和盐值一起存进数据库。校验时拿出盐值,对用户提交的密码做同样的哈希,和数据库里的结果比对。这样即使两个用户密码相同,存出来的哈希值也不同。
在Shiro中注册HashedCredentialsMatcher时三件套要配齐:哈希算法、哈希迭代次数、存储格式。我在项目里常用这么一段来生成密码:
String salt = RandomStringUtils.randomAlphanumeric(16); String hashedPassword = new SimpleHash( "SHA-256", rawPassword, salt, 1024 ).toHex();写入Realm配置时,把同一个salt塞进SimpleAuthenticationInfo的第三参数,这样HashedCredentialsMatcher就知道怎么重算了。关于迭代次数,1024次是我在各项目里的保守选择。不是越高越好,因为每次登录都要重复计算,太高的迭代次数会明显拖慢接口响应。如果用到SHA-512或更慢的算法,300到500次也比较稳妥。关键是“随机盐 + 足够多迭代”,这两条满足以后密码存储基本是安全的。
4. 会话管理与并发登录控制
4.1 Shiro会话语义与默认配置
Shiro的会话管理是它区别于很多拦截器方案的地方。普通的Servlet Session由Tomcat这类容器管理,但Shiro可以脱离容器独立管理,这意味着同样一套会话机制可以同时服务Web请求和非Web请求。在Web项目里,Subject.getSession()拿到的就是Shiro会话,它底层和HttpSession有关系,但外层语义完全由Shiro控制。
默认情况下DefaultWebSessionManager会把会话ID通过Cookie写给浏览器,Cookie名称默认是JSESSIONID。有一点需要注意:Shiro的Cookie是有失效时间的,会话超时时间通过sessionManager.setGlobalSessionTimeout()控制,单位是毫秒。假如不设置,默认大概是30分钟。如果项目里希望用户长时间保持登录,可以把超时时间调大,但千万不要无限延长,否则安全风险很高,而且后端长期保存大量会话也会吃内存。
这里还要提到一个常见的配置项sessionValidationInterval。它表示会话验证的周期,Shiro会定期扫描过期会话并清理。默认值和全局超时相关,如果测试时发现会话迟迟不失效,多半是这个扫描周期太长。调整这个参数时记住一个原则:扫描越频繁越及时,但代价是消耗一些CPU。
4.2 会话持久化:把Session交给Redis
只使用本地内存的会话管理有一个致命的局限:应用重启后会话全部消失,用户被迫重新登录。部署多实例时,用户在一台机器上登录,下一次请求被负载均衡转到另一台机器,会话就丢了。解决这个问题的方法是实现一个自定义的SessionDAO,让会话数据落到Redis里。
大概思路是统一接管Session的创建、读取、更新和删除。Shiro的AbstractSessionDAO提供了骨架,你需要实现doCreate、doReadSession、update和delete等方法。在Redis里存储时,我习惯用“shiro:session:”前缀加会话ID作为Key,Value用JDK序列化或JSON序列化都行。需要注意Redis中的过期时间要和会话超时时间保持一致,这样Redis自动淘汰过期Key,不用额外写清理任务。
每次请求进来时,DefaultWebSessionManager会从Cookie取出会话ID,通过SessionDAO从Redis加载Session。如果Redis里没有,那就表示会话已失效,请求会被踢到登录页。这个方案有几个坑:一是序列化工具要全局一致,否则CRUD混乱;二是写入Redis的数据量别太大,Session里不要塞大对象;三是Redis连接池要高可用,否则Redis宕机会导致整个系统无法登录。
4.3 强制下线与并发登录控制
后台系统有个高频需求:管理员踢人,或者限制同一个账号同时只能在一处登录。Shiro原生没有直接提供“踢人”API,需要基于Session的机制自己实现。最直接的做法是维护一个在线用户列表,把用户登录后的SessionID记录到某个可查找的地方,比如Redis里的一个Hash结构,字段是用户ID,值是SessionID。
当需要踢人的时候,找到对应的SessionID,然后执行"停止会话"的操作。在Shiro里可以这样做:从SecurityManager获取SessionManager,再通过SessionDAO读取对应的Session对象,调用stop()方法。会话停止后,用户下一次请求就会因为会话失效被打回登录页。如果控制台同时限制单用户登录,在用户新登录时先查一下Hash结构里这个用户是否已有Session,有的话就先停掉旧会话,再让新会话上线。
实际踩坑提醒:用Java原生的Map存userId -> sessionId在单体测试没问题,但一旦部署多实例就失效了,因为每个实例的Map是独立的。多实例环境建议直接把在线关系也放到Redis,并给Key设置合理的过期时间,防止用户长期不操作时留下脏数据。
5. Spring Boot整合实操过程
5.1 依赖引入与配置类编写要点
Spring Boot引入Shiro很简单,关键在版本兼容。目前主流的做法是引入shiro-spring包,我建议查看官方最新的版本列表,选一个和当前Spring Boot版本兼容的稳定版。你不需要引入太多模块,对Web项目来说,shiro-spring和shiro-web加在一起就覆盖了大部分功能。
配置类要管三件事:创建Realm、创建SecurityManager、创建ShiroFilterFactoryBean。Realm的Bean要注册到Spring容器里,SecurityManager要接收Realm作为依赖,ShiroFilterFactoryBean获取SecurityManager后配置过滤链。我通常还会配上LifecycleBeanPostProcessor和AuthorizationAttributeSourceAdvisor,前者负责管理Shiro内部组件的生命周期,后者是启用@RequiresPermissions这类注解的关键。漏掉AuthorizationAttributeSourceAdvisor,注解权限会静默失效,这是排查问题时第一个要检查的地方。
过滤链的顺序千万不能搞错。Shiro的表单过滤器和支持匿名访问的匿名过滤器有匹配顺序问题,代码里其实已经体现出来了。比如将要过滤的URL规则放入LinkedHashMap中,每次匹配时它会从上往下找第一个匹配的规则。如果把/** = authc写在前面,后面的/login = anon就永远没有机会生效,登录页也被拦截了。
5.2 ShiroFilterFactoryBean与过滤链的正确配置
过滤链是Shiro穿插进请求过程的核心机制。每个请求经过Shiro过滤器时,会被一个过滤器链处理,链上的每个过滤器决定放行、拦截或跳到登录页。Shiro内置了多个过滤器:anon允许匿名访问,authc要求登录认证,logout主动注销,roles要求角色,perms要求权限字符串。
我在项目里常用下面这份配置发现它非常直观:
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); factoryBean.setLoginUrl("/login"); factoryBean.setSuccessUrl("/index"); factoryBean.setUnauthorizedUrl("/403"); Map<String, String> filterChainMap = new LinkedHashMap<>(); filterChainMap.put("/login", "anon"); filterChainMap.put("/logout", "logout"); filterChainMap.put("/css/**", "anon"); filterChainMap.put("/js/**", "anon"); filterChainMap.put("/captcha", "anon"); filterChainMap.put("/**", "authc"); factoryBean.setFilterChainDefinitionMap(filterChainMap); return factoryBean; }两个经验教训:第一,静态资源一定要放在所有规则的最前面并标记anon,否则用户还没登录就什么都看不到,页面全被拦截进登录页了;第二,把登录接口标记为anon是必要的,但登录接口不能同时被authc拦截,否则就形成了死循环。另外logout过滤器是一个特殊的工具,它会在执行登出逻辑后直接跳到登录页,不用在代码里手动写退出接口。
5.3 权限注解与接口级别的权限控制
过滤链解决的是URL级别的控制,注解解决的是方法级别的控制。两者可以配合使用,但职责要分清楚:粗粒度的页面和接口拦截用过滤链,细粒度的操作权限用注解。注解方式非常简洁,在Controller方法上标注@RequiresPermissions("user:delete"),没有权限的用户会被拒绝并跳转到未授权页面。
常用注解有三个:@RequiresRoles、@RequiresPermissions和@RequiresAuthentication。用的时候注意一个坑:注解只对Spring管理的Bean方法有效。Spring AOP代理没有配置好,或者方法被同类内部调用,都可能绕过代理导致注解不生效。对于多个权限要求,“或”的关系和“与”的关系要用logical = Logical.OR或Logical.AND明确指定,否则默认语义可能和你理解的不一致。
另外一个容易误导人的地方:过滤链即使配置了权限字符串过滤,比如/admin/** = perms["admin:manage"],它也只对精确匹配这个权限字符串的用户生效。很多新手以为配置了过滤链就可以不用注解了,实际上过滤链更常用在页面级别,比如整个后台功能路径都要求登录;细粒度的“这个按钮只有管理才能点”,还是交给注解做更清晰。
5.4 前后端分离模式下的配置变通
如果你做的是前后端分离项目,Shiro的配置思路要跟着调整。传统后台有服务端页面跳转,Shiro默认跳转loginUrl的行为是直接返回登录页的HTML。但在前后端分离下,后端只返回JSON,前端拿到401或403状态码自行跳转。Shiro显然也能支持这种模式,只是需要做一些定制。
我常用的办法是自定义一个过滤器或者重写认证失败处理。最简单的方式是保留authc过滤器,但把setLoginUrl改成真正的登录接口路径,同时写一个全局异常处理器捕获AuthenticationException和AuthorizationException,返回统一JSON格式。另一个可选方案是继承FormAuthenticationFilter重写onAccessDenied方法。无论哪条路,核心思路都是把Shiro的页面跳转行为替换成状态码或JSON返回。
还有一个经常被忽略的细节:跨域问题。前后端分离时前端和后端域名往往不同,浏览器会先发一个OPTIONS预检请求。如果Shiro过滤链把所有未登录请求都拦截,那么预检请求也会被拦截,导致前端拿不到响应。解决办法是在过滤链最前面显式放行所有OPTIONS请求,或者自定义过滤器直接跳过OPTIONS请求,只对真正的业务请求做安全判断。这个坑我见过太多次了,排查半天最后竟然是拦了预检请求。
6. 常见问题与排查技巧实录
6.1 认证相关的坑与排查
认证阶段的问题通常集中在登录跳转和服务端报错上。一个非常常见的问题是登录成功后无限重定向或退回登录页。这往往不是密码错误引起的,而是过滤链顺序错了。举个例子,如果/** = authc出现在/login = anon之前,那登录页本身也被拦截,系统会一直把用户往登录页赶。要做的第一件事就是检查过滤链用LinkedHashMap保存的顺序。
第二个常见问题是登录时报IncorrectCredentialsException,但密码明明没输错。这种情况八成是认证信息里的密码类型对不上。比如数据库里密码字段存的是十六进制字符串,但Realm里返回的是字节数组,凭据匹配器一比较就发现类型不匹配。排错时可以临时在Realm里打印出两边数据的类型和内容,确认完全一致后再做哈希校验。
第三个问题是用户被锁定却还是能登录。如果数据库里有状态字段,那必须在doGetAuthenticationInfo里主动判断并抛出LockedAccountException。Shiro不会自动读取用户状态,你可以不抛异常它就不管。养成一个习惯:在Realm里把所有账号状态相关的判断都做掉,别等到Service层才发现状态有问题。
6.2 权限不生效的典型场景
注解权限不生效是我收到问题里频率最高的。从经验来看,50%的情况是AuthorizationAttributeSourceAdvisor没有注册到Spring容器里,导致@RequiresPermissions注解被完全忽略。另外30%是方法没有被Spring代理,比如在同一个类内部调用带注解的方法,绕过了代理。还有20%是Realm返回的权限字符串和注解里的字符串对不上,比如大小写不一致、冒号写成了点号。排查思路:先确认注解能不能生效,再确认数据层返回的权限列表。
有一种情况我确实经常看到:用户有某个角色,但过滤链配置的是权限字符串,两者对不上,页面就怎么都进不去。记住一条规则:角色和权限在Shiro中是两套独立的体系,roles过滤器判断角色,perms过滤器判断权限,不要混着用,否则检查半天都找不到原因。
还有一种隐蔽的错误:缓存了授权信息导致权限修改后不生效。一旦配置了CacheManager,doGetAuthorizationInfo返回的权限集合会缓存起来。用户在后台改了权限,理论上需要重新登录或者清理缓存才能看到效果。排查这类问题的方法是在代码里显式调用subject.getSession().removeAttribute或者通过SecurityManager清理该用户的认证缓存。如果项目对权限变更要求实时生效,就必须设计缓存清理方案。
6.3 会话与并发问题速查表
会话和并发问题的定位相对容易,我把常见的现象、原因和处理方式整理成一个速查表,平时排错先看这张表能省很多时间。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 应用重启后所有用户都要重新登录 | 会话保存在本地内存,未持久化 | 配置自定义SessionDAO存储到Redis |
| 多实例部署下会话随机失效 | 会话没有在多节点间共享 | 引入Redis会话共享方案 |
| 会话超时时间不生效 | 会话验证周期太长或配置位置错误 | 调整sessionValidationInterval与globalSessionTimeout |
| 用户被强制下线后还能操作 | 旧的会话没有被同步停止 | 执行踢人时通过SessionDAO拿到旧会话并stop |
| 同一账号重复登录不受限 | 没有做并发登录控制 | 在登录成功后记录会话ID,重复登录时踢掉旧会话 |
| 跨域请求发送OPTIONS被拦截 | 过滤链拦截了预检请求 | 在过滤链中放行OPTIONS请求或自定义过滤器处理 |
6.4 几个容易忽略的安全细节
排查问题之余,我还想提到几个平时容易忽略的安全细节。第一个是路径匹配。很多系统用/**拦截所有路径,但Shiro的路径匹配有自己的规则,如果一个URL包含分号或特殊编码,可能导致过滤器匹配不上。在安全要求较高的场景下,建议给容器配置严格的路由,并自定义过滤器处理路径规范化问题。
第二个是Session固定攻击。用户在登录前拿到一个会话ID,登录后如果会话ID不更换,攻击者可以通过固定会话ID冒充用户。Shiro默认的会话管理在登录前后会重建会话,但有些自定义方案会丢掉这个特性。如果自己接管会话流程,必须确保登录成功后会话ID被重新生成。
第三个是权限字符串设计。建议不要用没有明确边界的权限字符串,比如只用一个字符串admin表示所有权限,这样后期想给某个用户开放部分权限就完全没法做。尽量拆分成“资源:操作”这样的格式,配合通配符使用,这样维护起来灵活得多。
最后一个细节是RememberMe。它虽然方便,但默认实现是序列化到Cookie里,存在被篡改和反序列化的风险。如果项目必须使用它,一定要配置强密钥,并且不要让未登录用户也持有有效会话。实际项目中我一般建议默认关闭RememberMe,真需要就单独做安全的持久化登录方案。
我在实际项目里使用Shiro的体会是:这个框架最让人安心的地方在于它把复杂的认证授权流程收敛成了几条明确的组件和配置路径。刚开始接触时,配置文件看上去像一团乱麻,但弄清楚过滤链的顺序、Realm的职责、凭据匹配的机制之后,剩下的事基本就是按部就班地填业务数据。如果你手头有一个项目正在做权限改造,照着这条主线一步一步梳理下来,比到处搜“某个配置为什么没用”要靠谱得多。最后再分享一个习惯:每次调整过滤链前,先用测试用例把登录、登出、未授权三条路径全部跑一遍,再小的改动都别跳过,这个习惯能在后续改造里帮你省掉大量排错时间。