ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot整合Shiro权限管理实战:认证授权与缓存避坑指南

SpringBoot整合Shiro权限管理实战:认证授权与缓存避坑指南 1. 权限框架选型背后的真实考量做后台系统绕不开登录和权限这两块我见过太多项目在这上面翻车。有的用拦截器加Session硬写代码散落在各个Controller里改一个权限规则要翻十几个文件有的直接上Spring Security结果被那一套过滤器链和配置类绕得晕头转向最后权限没管明白登录还时不时出问题。SpringBoot整合Shiro这套组合是我在中小型项目里用得最顺手、踩坑最少的方案没有之一。Shiro的核心优势在于它的抽象层次刚刚好。它不像Spring Security那样把认证和授权拆成十几个组件让你自己组装而是用SecurityManager、Subject、Realm三个核心概念就把事情说清楚了。Subject代表当前操作的用户SecurityManager是幕后总管Realm是连接你数据库的桥梁。你只需要告诉Shiro“怎么查用户”和“怎么查权限”剩下的会话管理、密码加密、记住我、验证码校验这些它全帮你兜住。这套方案适合谁如果你正在做一个后台管理系统、企业内部工具、或者任何需要区分管理员和普通用户权限的Web应用Shiro都能在半天内帮你把骨架搭起来。它不挑数据库不挑缓存实现甚至不挑Web框架SpringBoot只是让它更省事而已。我试过在一个老SSM项目里单独引入Shiro只加几个配置类就跑通了迁移成本极低。但要注意Shiro不是银弹。如果你的系统需要OAuth2、JWT无状态认证、或者微服务间的细粒度权限控制Shiro的原生支持会比较吃力需要大量自定义扩展。所以选型前先想清楚你是要做传统的Session-based后台系统还是要做前后端分离的Token认证前者Shiro是绝配后者建议看看Sa-Token或者Spring Security JWT的组合。2. 核心概念拆解与依赖配置2.1 Shiro三大核心组件到底在干什么很多人看Shiro文档时被SecurityManager和Subject的关系绕晕我用一个生活化的类比来解释。把整个权限系统想象成一家公司Subject就是来办事的员工每次进门都要刷卡SecurityManager是前台保安队长他手里有所有员工的档案和门禁规则Realm是人事部保安队长需要核实身份时就打电话给人事部查档案。你作为开发者只需要把人事部的档案整理好实现Realm保安队长会自动处理所有进出验证。具体到代码层面Subject通过SecurityUtils.getSubject()获取它封装了当前请求的用户信息。SecurityManager在SpringBoot中通常由ShiroFilterFactoryBean自动装配你不需要手动new。Realm是你唯一需要重点实现的部分继承AuthorizingRealm后重写doGetAuthenticationInfo认证和doGetAuthorizationInfo授权两个方法即可。这里有个关键细节Shiro的认证和授权是分开走的。认证只发生在登录那一刻授权则在每次访问受保护资源时都会触发。这意味着如果你的权限数据查询很慢每次请求都会拖慢响应。我一般会在Realm的授权方法里加一层缓存用Redis或者Caffeine都行后面会详细说。2.2 SpringBoot项目中的依赖引入与版本选择依赖这块有个坑我踩过Shiro的starter包和原生包混用会导致Bean冲突。正确的做法是只引入shiro-spring-boot-web-starter它会自动帮你装配SecurityManager和ShiroFilter。如果你用的是前后端分离项目不需要Session那就引入shiro-spring-boot-starter然后手动配置过滤器。dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring-boot-web-starter/artifactId version1.13.0/version /dependency版本选择上1.13.0是目前稳定版修复了之前几个反序列化漏洞。不要用1.7以下的版本有已知安全问题。另外注意如果你的项目用了SpringBoot 3.xShiro的jakarta包需要单独引入因为javax.servlet已经改成jakarta.servlet了。这个坑我在升级项目时遇到过启动直接报ClassNotFoundException排查了半天才发现是包名问题。提示引入starter后Shiro会自动注册一个默认的ShiroFilterChainDefinition但通常你需要自定义过滤链规则所以建议还是手动配置一个ShiroFilterChainDefinition Bean。3. 认证流程的完整实现与细节打磨3.1 自定义Realm的认证逻辑怎么写才稳认证的核心在doGetAuthenticationInfo方法。这个方法接收一个AuthenticationToken里面装着用户提交的用户名和密码。你的任务是根据用户名去数据库查用户如果查不到就抛UnknownAccountException如果查到了把数据库里的密码和盐值封装成SimpleAuthenticationInfo返回Shiro会自动帮你比对密码。Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken upToken (UsernamePasswordToken) token; String username upToken.getUsername(); User user userMapper.findByUsername(username); if (user null) { throw new UnknownAccountException(用户不存在); } if (user.getStatus() 0) { throw new LockedAccountException(账号已被锁定); } ByteSource salt ByteSource.Util.bytes(user.getSalt()); return new SimpleAuthenticationInfo(user, user.getPassword(), salt, getName()); }这里有几个容易出问题的地方。第一盐值必须和注册时使用的一致否则密码永远比对不上。我见过有人注册时用随机盐存库登录时却用固定盐结果怎么都登不进去。第二SimpleAuthenticationInfo的第一个参数传的是user对象这个对象会在授权阶段被用到所以最好把用户ID和用户名都放进去。第三异常类型要区分清楚UnknownAccountException和IncorrectCredentialsException在前端要展示不同的提示前者是“用户不存在”后者是“密码错误”但出于安全考虑很多项目会统一提示“用户名或密码错误”防止攻击者枚举用户名。3.2 密码加密与盐值处理的正确姿势密码绝对不能明文存库这是底线。Shiro提供了HashedCredentialsMatcher支持MD5、SHA-256等算法。但单纯MD5已经不安全了彩虹表一查就出来。正确的做法是每个用户生成一个随机盐值用SHA-256加盐哈希后存库。public class PasswordUtil { public static String generateSalt() { SecureRandom random new SecureRandom(); byte[] salt new byte[16]; random.nextBytes(salt); return Base64.getEncoder().encodeToString(salt); } public static String encrypt(String password, String salt) { return new SimpleHash(SHA-256, password, ByteSource.Util.bytes(salt), 1024).toHex(); } }注意这里的1024是迭代次数意味着哈希函数要跑1024次。这个数字越大越安全但登录时的计算耗时也越长。我实测下来1024次在普通服务器上大约耗时50毫秒用户体验可以接受。如果你追求更高安全性可以调到10000次但登录接口的响应时间会明显增加需要权衡。配置HashedCredentialsMatcher时要把算法名称、迭代次数和是否加盐都设置好然后注入到自定义Realm中。这里有个细节如果你在Realm里手动返回了带盐的SimpleAuthenticationInfo那么CredentialsMatcher会自动从里面取盐不需要额外配置salt参数。3.3 登录接口与Session管理的配合登录接口本身很简单构造UsernamePasswordToken调用subject.login()捕获异常做相应处理。但Session管理有几个坑要注意。Shiro默认会创建Session如果你在前后端分离项目里不需要Session要记得关闭。另外Session超时时间默认是30分钟可以在application.yml里配置。PostMapping(/login) public Result login(RequestBody LoginDTO dto) { Subject subject SecurityUtils.getSubject(); try { subject.login(new UsernamePasswordToken(dto.getUsername(), dto.getPassword())); return Result.success(登录成功); } catch (UnknownAccountException e) { return Result.fail(用户名或密码错误); } catch (LockedAccountException e) { return Result.fail(账号已被锁定); } catch (AuthenticationException e) { return Result.fail(认证失败); } }登录成功后用户信息会存在Session里后续请求Shiro会自动从Session中恢复Subject。这里有个性能优化点如果用户信息经常变可以在每次请求时刷新Session中的用户对象否则会出现权限更新后不生效的问题。我一般会在Realm的授权方法里重新查一次用户权限而不是依赖Session里的缓存。4. 授权控制的多种实现方式对比4.1 基于过滤链的URL级别权限控制这是最粗粒度的权限控制在ShiroConfig里定义过滤链规则。比如/login和/register放行/admin/**需要admin角色/user/**需要登录。这种方式的优点是配置集中一眼就能看出整个系统的权限布局缺点是粒度太粗无法控制到具体按钮或数据行。Bean public ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition chain new DefaultShiroFilterChainDefinition(); chain.addPathDefinition(/login, anon); chain.addPathDefinition(/register, anon); chain.addPathDefinition(/admin/**, authc, roles[admin]); chain.addPathDefinition(/**, authc); return chain; }过滤链的匹配顺序是从上到下一旦匹配到就停止。所以/**必须放在最后否则后面的规则永远不会生效。这个坑我见过不止一次有人把/**放在第一行结果所有接口都要求登录连登录接口本身都被拦截了直接死循环。4.2 注解驱动的细粒度权限控制在Controller方法上加RequiresPermissions或RequiresRoles注解可以实现方法级别的权限控制。这种方式比过滤链灵活得多适合控制具体操作。但要注意注解生效需要开启Shiro的AOP支持在ShiroConfig里注册AuthorizationAttributeSourceAdvisor。RequiresPermissions(user:delete) DeleteMapping(/user/{id}) public Result deleteUser(PathVariable Long id) { userService.deleteById(id); return Result.success(); }注解里的权限字符串建议遵循“资源:操作”的命名规范比如user:add、user:edit、user:delete。这样在数据库里存储和查询都很清晰。授权时Realm的doGetAuthorizationInfo方法返回用户拥有的所有权限字符串Shiro会自动比对。注意RequiresPermissions注解在同一个类中方法互相调用时不会生效因为AOP代理的是外部调用。如果你在Service层用注解要确保是通过Spring注入的代理对象调用而不是this调用。4.3 页面按钮级别的权限渲染后端控制住了接口权限前端页面上的按钮也要根据权限显示或隐藏。Thymeleaf有Shiro的方言包JSP也有对应的标签库。如果是前后端分离项目可以在登录成功后返回用户的权限列表前端用v-if或v-show控制按钮显示。button shiro:hasPermissionuser:delete onclickdeleteUser()删除/button这里有个原则前端隐藏按钮只是提升用户体验真正的安全防线在后端。我见过有人只在前端隐藏了删除按钮后端接口却没加权限校验结果用户直接调接口就把数据删了。所以后端注解和前端隐藏要同时做缺一不可。5. 缓存与Session持久化的实战配置5.1 用Redis缓存权限数据减轻数据库压力每次请求都查数据库获取权限列表在高并发场景下数据库扛不住。Shiro支持CacheManager可以无缝接入Redis。配置好后doGetAuthorizationInfo方法只在第一次请求时执行后续都走缓存。Bean public CacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheManager redisCacheManager new RedisCacheManager(factory); return new ShiroRedisCacheManager(redisCacheManager); }这里需要自己实现一个ShiroRedisCacheManager把Shiro的Cache接口适配到Redis。网上有现成的实现核心就是实现get、put、remove、clear这几个方法。缓存key建议用“shiro:auth:” username的格式方便排查问题。缓存带来的问题是权限更新不及时。用户权限被修改后缓存里的旧权限还在需要手动清除。我一般会在权限修改的Service方法里调用shiroCache.remove(username)确保下次请求重新加载。如果权限变动不频繁也可以设置一个较短的过期时间比如5分钟让缓存自动失效。5.2 Session共享与序列化问题如果项目部署了多个节点Session需要共享否则用户请求打到不同节点会反复要求登录。Shiro的Session可以通过Redis持久化配置一个RedisSessionDAO即可。但这里有个大坑Session里的对象必须实现Serializable接口否则序列化会失败。Bean public SessionDAO sessionDAO(RedisTemplateString, Object redisTemplate) { RedisSessionDAO sessionDAO new RedisSessionDAO(); sessionDAO.setRedisTemplate(redisTemplate); sessionDAO.setExpire(1800); return sessionDAO; }我踩过的坑是User对象里包含了一个LocalDateTime字段序列化时抛异常。后来把所有时间字段改成String或者Long才解决。另外如果User对象关联了其他实体那些实体也要实现Serializable否则会连锁报错。建议Session里只存用户ID和用户名需要详细信息时再查数据库这样既减小序列化体积又避免各种序列化问题。6. 常见问题排查与避坑经验实录6.1 登录后立刻跳回登录页是什么原因这个问题我遇到过三次每次原因都不一样。第一次是过滤链配置顺序错了/**放在了最前面导致登录接口也被拦截。第二次是Session超时时间设置太短登录成功后Session立刻过期。第三次最隐蔽前端请求没有带上Cookie因为跨域配置里没设置withCredentials。排查思路先看浏览器控制台有没有Cookie再看后端日志里Session是否创建成功最后检查过滤链配置。如果是前后端分离项目还要确认CORS配置里allowCredentials(true)和allowedOrigins不能是*必须是具体域名。6.2 权限注解不生效的几种可能RequiresPermissions加了但没起作用通常是以下几个原因第一没有注册AuthorizationAttributeSourceAdvisorAOP没生效第二Controller没有被Spring代理比如手动new出来的对象第三权限字符串拼写错误数据库里存的是user:delete注解里写的是user:del。排查时可以在doGetAuthorizationInfo方法里打日志看看返回的权限集合里到底有没有那个权限。如果没有就是数据库查询的问题如果有但注解还是不生效那就是AOP配置的问题。6.3 密码比对失败的排查步骤密码比对失败是最常见的问题排查步骤我整理成了一张表排查项检查方法常见问题盐值是否一致对比注册和登录时的盐值注册用随机盐登录用固定盐加密算法是否一致检查HashedCredentialsMatcher配置注册用SHA-256配置写MD5迭代次数是否一致检查SimpleHash的迭代参数注册1024次配置1次数据库密码长度查看密码字段长度varchar(32)存不下SHA-256的64位结果空格和大小写检查用户名前后是否有空格前端传参带了空格我建议在开发阶段把加密后的密码和盐值打印出来和数据库里的值逐字符对比很快就能定位问题。6.4 记住我功能失效的处理Shiro的RememberMe功能依赖Cookie如果失效先检查Cookie是否被浏览器拦截。另外RememberMe的密钥要配置默认是随机生成的重启后旧Cookie就失效了。在ShiroConfig里设置一个固定的密钥即可。Bean public CookieRememberMeManager rememberMeManager() { CookieRememberMeManager manager new CookieRememberMeManager(); manager.setCipherKey(Base64.getDecoder().decode(固定的Base64密钥)); return manager; }密钥必须是Base64编码的长度有要求太短会报错。我一般用AES的128位密钥生成一次后固定写在配置里。注意不要把这个密钥提交到代码仓库放在环境变量或配置中心里。7. 从认证到授权的完整链路回顾整套流程跑下来核心链路是这样的用户提交用户名密码Controller构造Token调用subject.login()SecurityManager委托Realm的doGetAuthenticationInfo查库比对密码比对通过后创建Session。后续每次请求ShiroFilter根据过滤链判断是否需要认证需要则从Session恢复Subject然后根据注解或过滤链规则触发doGetAuthorizationInfo查权限比对通过才放行。这条链路上每个环节都有优化空间。认证环节可以加验证码、限制登录失败次数授权环节可以加缓存、支持动态权限Session环节可以持久化到Redis、支持多端登录互踢。我一般会先跑通基础版本再根据项目实际需求逐步叠加这些功能避免一上来就搞得太复杂反而容易出问题。最后分享一个我常用的调试技巧在ShiroConfig里把日志级别调到DEBUGShiro会打印每次认证和授权的详细过程包括匹配了哪条过滤链、查询了哪些权限、比对结果是什么。这个日志在排查权限问题时比任何断点都管用强烈建议在开发环境打开。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进