ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot集成Shiro:从过滤器链到RBAC权限管理实战

Spring Boot集成Shiro:从过滤器链到RBAC权限管理实战 简介基于Spring Boot与Shiro的权限管理系统面向需要快速搭建后台权限模块的Java开发人员与正在学习Spring生态的初学者解决用户认证、授权、会话管理等通用安全问题。资源包内含306个文件压缩后仅1.82MB其中108个Java源文件覆盖Controller、Service、Mapper等分层逻辑56个JavaScript与34个HTML负责前端页面交互22张PNG等图片资源用于界面展示另有XML、SQL、YML等配置与数据库脚本整理清晰、便于直接导入项目运行。项目结合Shiro框架实现了基于角色的访问控制读者可借此掌握权限拦截、密码加密、会话管理及与Spring Boot整合的典型写法所有代码结构完整适合作为毕业设计或企业后台管理系统的参考底座。目前已有18人学习下载适合希望以较短时间理解RBAC权限实现原理的开发者参考。1. 从登录态到接口鉴权为什么 Spring Boot 项目需要 Shiro很多后台系统在交付时只有一层登录拦截只要拿到会话标识就能顺着/user/list、/order/export一路翻下去。权限校验一旦靠“前端把菜单藏起来”实现等于把门锁交给访客自己决定拧不拧。Shiro 在这种场景下的价值是把认证、授权、会话管理收拢成一条可配置的过滤器链配置成本比 Spring Security 低一截又足够覆盖中小型企业管理系统的权限诉求。这套基于 Spring Boot 与 Shiro 的权限管理系统前端资源包含 bootstrap-table、sweetalert2、multiple-select 等常见的后台组件后端则是标准的 RBAC 用户-角色-权限模型。适合刚接手后台脚手架、准备把拦截器升级为完整权限体系或者想在一套可复现的代码上理解 Shiro 工作方式的读者。2. Shiro 认证授权链路与 Spring Boot 集成配置2.1 四个核心对象与过滤器链的匹配顺序Shiro 的整个工作过程可以压缩成四个对象的关系Subject 代表当前操作者SecurityManager 是唯一的核心门面Realm 是连接数据源的桥梁而 Authenticator 和 Authorizer 分别负责“证明你是谁”和“确认你能做什么”。一次请求进来先经过 ShiroFilter 注册的过滤器链链上的每个过滤器按 URL 匹配规则决定放行、拦截或接管。过滤器链的匹配顺序是很多初次接触者踩坑的地方。在 ShiroFilterFactoryBean 里用 LinkedHashMap 维护的 filterChainDefinitionMap匹配规则是“从上往下、先命中先生效”与 Spring Security 的按顺序匹配一致但与 Servlet Filter 的路径匹配习惯相反。常见做法是把/**放在最后一条把/login、/static/**、/captcha这类匿名可访问的资源放在前面否则一旦/** authc先命中匿名接口全被拦死。关键过滤器缩写与 Java 类名对应关系如下表配置文件里写的是缩写调试时看到堆栈里的类名要能对得上缩写Java 类作用anonAnonymousFilter匿名可访问不产生会话authcFormAuthenticationFilter未认证则跳转登录页或返回 401userUserFilter认证过或记住我的会话可访问permsPermissionsAuthorizationFilter校验当前用户是否拥有指定权限标识rolesRolesAuthorizationFilter校验当前用户是否属于指定角色logoutLogoutFilter清理会话并跳转配置的登出地址这套映射关系在 Shiro 1.x 与 2.x 中基本保持稳定区别主要在于 Starter 坐标、包名从 javax 迁移到 jakarta以及部分默认过滤器注册方式的调整。排查问题时先按缩写找到对应 Java 类再根据堆栈里的行为判断是配置顺序问题还是 Realm 返回值问题。2.2 Maven 依赖与 ShiroFilterFactoryBean 配置引入依赖时注意版本线与 Spring Boot 的对应关系。如果项目基于 Spring Boot 2.x最常见的坐标是 shiro-spring-boot-starter 1.9.1它把 shiro-core、shiro-web、shiro-spring 全部收拢进来无需再单独声明。如果已经迁移到 Spring Boot 3.xShiro 官方将适配版本推到了 2.x 线路类名变化不大但底层基于 Jakarta 命名空间直接拿 1.9.1 会抛 NoClassDefFoundError。社区里关于 Spring Boot 4.x 的讨论也开始了迁移前先确认 Shiro 官方依赖矩阵的兼容状态不要直接替换版本号。dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring-boot-starter/artifactId version1.9.1/version /dependency引入依赖只是第一步真正把过滤器链接进 Spring Boot 的是 ShiroFilterFactoryBean它负责把 URL 规则翻译成 Filter 实例并注册到 Servlet 容器。下面的配置展示了最常用的最小集合静态资源匿名可访问、登录接口匿名可访问、用户管理接口需要认证且具备列表权限、角色管理接口需要 admin 角色其余所有路径都必须认证。Configuration public class ShiroConfig { Bean public ShiroFilterFactoryBean shiroFilterFactoryBean( org.apache.shiro.mgt.SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 认证失败后跳转的登录页前后端分离时改为返回 JSON factoryBean.setLoginUrl(/login); MapString, String filterChainMap new LinkedHashMap(); filterChainMap.put(/static/**, anon); filterChainMap.put(/login, anon); filterChainMap.put(/logout, logout); // 权限标识必须与数据库里的 perms 字段完全一致 filterChainMap.put(/user/**, authc, perms[system:user:list]); filterChainMap.put(/role/**, authc, roles[admin]); filterChainMap.put(/**, authc); factoryBean.setFilterChainDefinitionMap(filterChainMap); return factoryBean; } }这里的顺序很有讲究/static/**与/login必须放在最前面。权限标识perms[system:user:list]会在后续 Realm 的 doGetAuthorizationInfo 里与数据库查询结果做精确匹配大小写和冒号分隔符都要与入库数据一致。roles[admin]走的是角色判断适合粗粒度隔离细粒度到按钮时更推荐 perms。提示filterChainMap 是有序结构/**必须放在最后一条URL 更具体的规则放在前面否则后面的规则永远不会被匹配到。2.3 自定义 Realm密码校验与权限数据从哪来过滤器链只负责“要不要拦”判定依据完全来自 Realm 的返回值。继承 AuthorizingRealm 后要同时实现两个方法doGetAuthenticationInfo 负责根据用户名查出账号、密码和盐值doGetAuthorizationInfo 负责在完成认证后查出当前用户拥有的角色与权限标识集合。常见做法是认证和授权分开查库避免一次登录把全部角色和权限都加载到内存里数据量大了之后认证会明显变慢。Component public class UserRealm extends AuthorizingRealm { Autowired private SysUserService sysUserService; Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { SysUser user (SysUser) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); // 授权查询可以加缓存否则每次请求都会查一次数据库 info.setRoles(sysUserService.findRolesByUserId(user.getId())); info.setStringPermissions(sysUserService.findPermsByUserId(user.getId())); return info; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { String username (String) token.getPrincipal(); SysUser user sysUserService.findByUsername(username); if (user null) { throw new UnknownAccountException(账号不存在); } // 库里的密码是 MD5(密码 盐) 的结果盐一般是 UUID return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName()); } }密码匹配这一步需要单独声明 HashedCredentialsMatcher 并绑定到 Realm 上否则 Shiro 会把数据库里的密文当明文直接比较任何账号都登不进去。Bean public UserRealm userRealm() { UserRealm realm new UserRealm(); HashedCredentialsMatcher matcher new HashedCredentialsMatcher(); matcher.setHashAlgorithmName(MD5); matcher.setHashIterations(1); realm.setCredentialsMatcher(matcher); realm.setCachingEnabled(true); return realm; }hashAlgorithmName与hashIterations必须和创建账号时使用的算法一致。如果历史数据用了 SHA-256 加两次迭代这里配成 MD5 一次迭代认证会直接报 IncorrectCredentialsException。改算法前先确认存量用户的密码字段格式最稳妥的做法是登录成功后按新算法重算并回写。3. 用户-角色-权限数据模型与 MyBatis 动态权限查询3.1 五张表搭建 RBAC 模型RBAC 的核心是“用户不再直接拥有权限而是通过角色间接获得”这套系统里表结构是经典的五张表用户表、角色表、权限表加上用户-角色关系表、角色-权限关系表。用户表与角色表是多对多角色表与权限表也是多对多关系表只放关联字段不带业务字段避免将来扩展权限类型时动主表结构。表名关键字段说明sys_userid, username, password, salt, status, create_time密码只存密文salt 单独列sys_roleid, role_name, role_key, statusrole_key 用于 roles[admin] 匹配sys_permissionid, parent_id, perms, menu_type, icon菜单也放在这张表用 menu_type 区分sys_user_roleuser_id, role_id联合主键删除用户时级联清理sys_role_permissionrole_id, permission_id分配权限时的写入目标菜单和权限共用一张表是这类管理系统的常态menu_type 用 0 表示目录、1 表示菜单、2 表示按钮权限点这样前端渲染菜单树和后端做权限判断拿到的是同一份数据。项目里看到的 bootstrap-table.css 对应列表页multiple-select.css 对应给用户分配角色时的多选下拉都属于这套模型的操作界面。实际开发中经常有人为了省事把角色字段直接做成 sys_user.role_id 逗号分隔第一版确实快但权限变更时要拆字符串再匹配事务也不好包后面基本都会返工成五张表。3.2 MyBatis 动态 SQL 按用户查权限标识从 Spring Boot 常见的四层架构来看权限查询集中在 mapper 层完成service 层只做组合与缓存。查询权限标识的核心是沿着 sys_user - sys_user_role - sys_role_permission - sys_permission 这条链路做内连接再加上 sys_permission 表的 perms 字段非空过滤。单用户多角色时会出现重复权限所以查询结果用 DISTINCT 去重。select idselectPermsByUserId resultTypejava.lang.String SELECT DISTINCT p.perms FROM sys_user u INNER JOIN sys_user_role ur ON u.id ur.user_id INNER JOIN sys_role r ON ur.role_id r.id AND r.status 0 INNER JOIN sys_role_permission rp ON r.id rp.role_id INNER JOIN sys_permission p ON rp.permission_id p.id WHERE u.id #{userId} AND p.perms IS NOT NULL AND p.perms ! /select这个 SQL 返回的是字符串列表每个元素形如system:user:add。Shiro 的 perms 过滤器会把这个集合与 URL 规则里的perms[system:user:add]做精确匹配因此 SQL 里不要拼接任何路径前缀权限标识的定义最好统一维护在 sys_permission.perms 字段中。如果项目用了 MyBatis-Plus可以把这段 SQL 放在继承 BaseMapper 的接口上直接用 Select 注解但 XML 的好处是权限变更时可以先用 explain 看索引命中情况。user_id、role_id 这两列在关系表里必须建联合索引数据量超过十万后INNER JOIN 走全表扫描会让每次登录后的授权查询都卡到几百毫秒。3.3 菜单树、按钮权限与前端资源的配合后端把权限查出来只是第一步菜单栏怎么按角色渲染才是日常开发里改得最频繁的部分。常见做法是先按当前用户查可见权限集合再查出全部菜单按 parent_id 聚合成树前端组装时把权限集合里不存在的菜单节点裁剪掉。这个裁剪逻辑放在前端可以减少往返请求但按钮级权限必须后端也拦一道前端隐藏按钮只是体验优化不是安全边界。function buildMenuTree(menuList, permSet) { const visible menuList.filter(m m.menuType 2 ? permSet.has(m.perms) : true ); // 再按 pid 分组常见误区是只过滤子节点根节点漏过滤 const map new Map(); visible.forEach(m map.set(m.id, { ...m, children: [] })); const roots []; map.forEach(m { if (m.pid map.has(m.pid)) { map.get(m.pid).children.push(m); } else { roots.push(m); } }); return roots; }注意这里的过滤逻辑menuType 为 2 的按钮权限节点直接依赖 permSet而目录和菜单节点要保留否则父级被裁掉后子级也跟着消失。过滤后再做 pid 分组避免先分组后过滤导致 pid 指向已被裁剪的父节点。项目静态资源里的 _all.css 通常是把多个组件样式合并后的产物配合这些前端脚本构成完整的后台布局。4. 会话共享、RememberMe 与接口鉴权实战4.1 用 Redis 保存 Shiro Session默认情况下 Shiro 的会话存在 JVM 内存里单机部署没问题一旦后面加一台实例做负载均衡用户登录后在 A 机器请求被分到 B 机器就变成未认证表现就是频繁跳登录页。解决方式是替换 SessionDAO 为 Redis 实现让会话落在外部存储中多实例共享同一份会话数据。Bean public RedisSessionDAO redisSessionDAO(RedisConnectionFactory factory) { RedisSessionDAO sessionDAO new RedisSessionDAO(); sessionDAO.setKeyPrefix(shiro:session:); sessionDAO.setSessionIdGenerator(new JavaUuidSessionIdGenerator()); return sessionDAO; } Bean public DefaultWebSecurityManager securityManager(UserRealm userRealm, RedisSessionDAO sessionDAO) { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(userRealm); DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); sessionManager.setSessionDAO(sessionDAO); // 关闭后 URL 上不会附带 jsessionid避免会话标识外泄 sessionManager.setSessionIdUrlRewritingEnabled(false); manager.setSessionManager(sessionManager); return manager; }keyPrefix 要按项目区分多个系统共用同一套 Redis 时能避免会话 key 互相覆盖。关闭 URL 重写是一个容易忽略的安全细节否则分享出去的链接会带着会话标识等于主动把登录态交给别人。4.2 RememberMe 的 Cookie 配置与签发时机记住我功能的本质是把用户标识以加密形式写进 Cookie下次访问时由 RememberMe 过滤器恢复会话不代表密码被保存。日常开发中最常踩的坑是表单登录时没有把 UsernamePasswordToken 的 rememberMe 置为 true前端勾选了“记住我”也无效。UsernamePasswordToken token new UsernamePasswordToken(username, password); token.setRememberMe(rememberMe); // 由登录页勾选框传入 Subject subject SecurityUtils.getSubject(); subject.login(token);Cookie 的存活时间由 RememberMeManager 决定默认是浏览器会话需要显式配置才生效。Bean public CookieRememberMeManager rememberMeManager() { CookieRememberMeManager manager new CookieRememberMeManager(); SimpleCookie cookie new SimpleCookie(rememberMe); cookie.setHttpOnly(true); cookie.setMaxAge(7 * 24 * 60 * 60); manager.setCookie(cookie); // 生产环境务必换成自己生成的 AES 密钥不能使用默认值 manager.setCipherKey(aesKey); return manager; }httpOnly 能挡住大部分脚本读取 Cookie 的尝试maxAge 决定免登录时长一般不超过 7 天。cipherKey 是 16 字节的 AES 密钥不同环境的密钥要独立管理否则升级环境后旧 Cookie 全部失效用户会被静默踢回登录页。4.3 注解鉴权与统一 401、403 响应在 Controller 方法上加 RequiresPermissions 是比 URL 规则更细的鉴权方式适合接口粒度与资源路径不对齐的场景。比如导出按钮只对拥有system:user:export的用户可见但导出接口路径是/export/userURL 规则写起来别扭注解直接标在方法上更直观。GetMapping(/export) RequiresPermissions(system:user:export) public ResponseEntitybyte[] exportUser() { // 导出逻辑 }注解生效依赖 Spring AOP 代理如果 Controller 被 final 修饰或方法是非 public 的注解会被静默忽略权限验证直接失效。前后端分离时Shiro 默认的“未登录跳转登录页”行为返回的是 HTML前端拿到 302 后只会一脸懵。处理方式是注册一个全局异常处理器拦截 AuthorizationException统一返回 JSON 结构认证失败返回 401授权失败返回 403前端根据状态码决定跳登录页还是提示无权限。项目资源里的 sweetalert2.css 通常就是用来承接这类提示弹窗的接口返回 403 后前端弹出“当前账号无此操作权限”点击确认回到首页比白屏或者控制台里的红色报错要友好得多。4.4 权限变更后的缓存清理Realm 开了 cachingEnabled 后doGetAuthorizationInfo 的结果会被缓存用户改了角色和权限后如果不清理缓存旧权限会一直生效到缓存过期。常见做法是在权限分配完成的方法里调用 Subject 的 logout 强制重新登录或者主动清掉该用户的授权缓存。public void clearUserAuthCache(Long userId) { // 通过 userId 找到 PrincipalCollection 再调用 realm.clearCachedAuthorizationInfo }如果项目里用户数量不大也可以把授权缓存过期时间设为 5 到 10 分钟换取权限变更的弱一致管理后台这种低频场景完全够用。5. 权限校验的验证清单与三个高频异常排查5.1 用接口请求跑通认证与授权两段验证拿到这套系统后不要急着点页面先按下面的顺序用 Postman 或 curl 把链路验证一遍能快速定位问题是出在 Shiro 配置还是业务代码未登录状态下请求/user/**预期收到 401 或跳转登录页。用拥有system:user:list权限的账号登录拿到会话标识。带会话重放同一接口预期返回 200。换一个没有该权限的账号访问同一接口预期返回 403。重点是确认 401 与 403 的响应体是 JSON内容里带上错误码前端才能据此做统一拦截。5.2 UnknownAccount、IncorrectCredentials 与 Unauthorized这三个异常是上线后被问得最多的。UnknownAccountException 表示查不到账号先检查 sys_user 表 username 字段是否精确匹配登录名带空格是最常见原因IncorrectCredentialsException 表示密码不匹配先确认 HashedCredentialsMatcher 的算法和迭代次数与建号时一致如果改过算法旧账号全部会报这个错UnauthorizedException 表示权限不足看数据库角色权限是否分配成功再确认 URL 规则里 perms 的大小写与 sys_permission.perms 完全一致。5.3 会话丢失与缓存残留的区分方法频繁被踢出登录状态时先看是不是 Redis 存储的会话在 Docker 重启后丢失再确认 sessionManager 的 globalSessionTimeout 是否配置过短。权限刚改完却不生效时优先怀疑授权缓存残留而不是代码写错可以在 Redis 客户端里按照shiro:session:*与授权缓存 key 前缀搜索找到后手动删除再重测。提示项目若引入 spring boot actuator非必要端点建议关闭必要时把/actuator/**纳入 authc 过滤避免暴露应用内部状态。清理缓存后重新登录观察 doGetAuthorizationInfo 是否被再次调用是判断权限链路是否恢复的最直接信号。这一步能排除掉一多半“改完没反应”的问题。本文还有配套的精品资源点击获取
返回列表