资讯详情

资讯详情

Apache Shiro自定义Realm授权实践:从认证到权限控制

1. 从认证跨到授权自定义 Realm 到底解决什么问题做了几年 Java 后端接手的大大小小项目里但凡用到 Shiro基本都逃不开同一个需求登录只是第一步登录之后不同角色能看到什么菜单、能调哪些接口才是甲方真正关心的东西。认证做得再顺授权这块如果没捋明白上线之后就是一堆“为什么他有权限我没有”“为什么按钮点不了”的工单。自定义 Realm 实现授权要解决的就是“把用户到底有哪些角色、哪些权限这件事从 Shiro 默认的静态配置里解放出来交给你自己的数据源和业务逻辑去回答”。这也是几乎所有生产项目绕不开的必经步骤。1.1 “能登录”和“能做哪些事”之间的鸿沟聊授权之前得先把概念对齐。认证解决的是“你是谁”授权解决的是“你能干什么”。Shiro 的 Subject 对这两件事提供了完全独立的两套 API登录的时候走doGetAuthenticationInfo确认账号密码对不对授权的时候走doGetAuthorizationInfo返回这个用户完整持有的角色和权限集合再交给框架去跟你要校验的权限表达式做比对。我在评审代码时经常看到一种情况认证方法写得漂亮登录、记住我、密码加盐全都安排上了但doGetAuthorizationInfo压根没重写或者重写了只是打了行日志就返回 null。结果就是用户登录一切正常hasRole(admin)永远返回 falsecheckPermission(order:export)直接抛AuthorizationException前端拿到 401 一脸懵。这个现象背后的原因无非两种一是你的 Realm 没实现授权方法二是实现了但返回的授权信息结构不对。把这两点搞清楚自定义 Realm 的授权部分就通了。1.2 内置 Realm 的授权能力边界Shiro 自带了几种开箱即用的 RealmIniRealm、PropertiesRealm、JdbcRealm。它们在教学和 demo 项目里确实很香写一行user/admin123456,admin就能快速把登录和角色跑起来JdbcRealm也支持通过三条 SQL认证查询、角色查询、权限查询直接从库里捞数据。但内置 Realm 在真实项目里往往撑不住原因很直接权限模型被写死了。JdbcRealm的每条查询 SQL 默认只能返回单列结果字段名和表结构跟你现有的库对不上就得改 SQL改完还得小心处理空结果集。权限粒度上它只能做到“权限字符串”这一层做不了实例级权限比如“只看自己创建的订单”“只能编辑自己部门的数据”。更麻烦的是一旦权限要跨数据源一部分来自数据库、一部分来自远程权限中心内置 Realm 基本没有任何扩展余地。所以生产项目里更常见、也更推荐的做法是继承AuthorizingRealm重写两个模板方法把“查权限、拼角色”这个动作自己扛下来。这就是自定义 Realm 的核心价值Shiro 把授权引擎和权限匹配算法都封装好了唯一留给你的入口就是doGetAuthorizationInfo你只要回答“这个用户能干什么”框架负责在每次权限校验时自动使用这个答案。2. 把授权链路看清楚才知道方法该写在哪个类里2.1 一次权限检查背后的完整委托链授权这件事Shiro 并不是直接调用你的 Realm 去判断“能不能”。它先把请求委托给SecurityManagerSecurityManager再转交给内部的Authorizer。默认情况下这个Authorizer是ModularRealmAuthorizer它会遍历SecurityManager里注册的所有 Realm逐个判断哪个能提供授权信息。每个 Realm 被询问“这个用户有没有某权限”时关键动作是调用自己的getAuthorizationInfo(PrincipalCollection principals)拿到这个用户完整的授权信息再拿授权信息与请求的权限对象做implies包含判断。请求方通常是WildcardPermission就是我们常写的“order:view”这种字符串。这条链路决定了 Realm 的唯一职责是提供素材而不是自己做判断。我在代码评审里见过有人把业务逻辑搬进授权方法直接在doGetAuthorizationInfo里调用SecurityUtils.getSubject().checkPermission(...)结果就形成了递归框架问 Realm 要权限信息Realm 又回头问框架要权限判断轻则性能损耗重则栈溢出。正确姿势是老老实实组装AuthorizationInfo把判断权完全交还给框架。2.2 AuthorizationInfo 到底装了什么AuthorizationInfo是个接口日常最常用的是SimpleAuthorizationInfo这个实现类。它里面主要有三个集合角色集合、字符串权限集合、对象权限集合。平时开发用到最多的就是前两个SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.addRole(admin); info.addStringPermission(order:export);这里有个容易忽略的点角色和权限是两套独立的检查维度。hasRole(admin)只看角色集合完全不会去看权限字符串isPermitted(order:view)只比对权限集合也不关心你有没有某个角色。如果项目采用的是“角色直接当权限用”的管理方式那你在doGetAuthorizationInfo里只addRole是不够的后面一旦有人用RequiresPermissions注解做接口拦截用户明明有角色也照样被拒。我建议从一开始就把角色和权限分开维护角色用于菜单显隐、前端按钮控制这类粗粒度场景权限字符串则对应后端接口的细粒度校验。两者在AuthorizationInfo里都填上后面怎么扩展都不慌。2.3 权限表达式的规则以及三个典型认知偏差Shiro 的权限字符串是用冒号分隔的多段结构最常见的三段是资源:操作:实例。order:export:2024表示对订单编号 2024 这一实例执行导出操作。同一段里多个取值用逗号分隔比如order:view,edit表示可查看可编辑。*是通配符order:*表示订单域下所有操作*:*表示所有域的所有操作直接写*也表示全部权限。第一个认知偏差在“包含关系”上。权限判断是包含关系不是相等关系。给用户配了order:*那么请求order:view:1024是可匹配的因为*盖住了操作层实例层自然也被覆盖。但如果只配了order:view来了个order:export那就直接拒绝因为操作层一个是 view 一个是 export。第二个偏差在“段数匹配”上。order:view和order:view:1并不是同一级权限前者描述的是“订单查看”这个动作后者描述的是“对订单 1 的查看动作”。如果业务上需要精确到记录级权限配置必须包含实例段如果不需要就别在请求里写第三段避免无谓的匹配失败。第三个偏差是空格。权限字符串里严禁出现空格order:view, order:edit这种带空格的写法Shiro 会把 order:edit当成独立权限值去比较结果永远匹配不上。我踩过这个坑后来在写权限数据入库前统一 trim 一遍并且用正则校验非法字符才彻底消停。3. 手写一个自定义 Realm五个关键步骤拆解3.1 第一步继承 AuthorizingRealm而不是从零实现自定义 Realm 最省事、也最正确的起点是继承AuthorizingRealm。这个类本身就继承了AuthenticatingRealm同时实现了Authorizer接口也就是说认证和授权的能力它都具备框架层面的通用逻辑比如权限缓存、身份匹配它都替你封装好了。你要做的只是重写两个模板方法doGetAuthenticationInfo(AuthenticationToken token)登录时根据 token 里的用户名返回AuthenticationInfo。doGetAuthorizationInfo(PrincipalCollection principals)授权时根据身份信息返回AuthorizationInfo。如果你图省事直接实现Realm接口后果就是要把hasRole、isPermitted、checkPermission这一堆方法全部手写一遍。这不是“自定义 Realm”这是重新造轮子。我在项目里见过绕开AuthorizingRealm硬撸授权的同事最后都在权限匹配的各种边界 case 上栽了跟头返工成本远高于一开始多写两行继承。3.2 第二步先让认证方法返回稳定可靠的 Principal授权方法拿到的PrincipalCollection本质上来源于认证时塞进SimpleAuthenticationInfo的 principal。我在项目里习惯把用户主键作为 principal同时把整个用户对象作为第二个参数一并塞进 principals 列表。这样授权回调时既能用主键去查权限表也能直接复用已经加载好的账号信息不用再去数据库多查一次用户表。一个典型的认证方法长这样Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username (String) token.getPrincipal(); User user userService.findByUsername(username); if (user null) { throw new UnknownAccountException(账号不存在); } return new SimpleAuthenticationInfo( user.getId(), user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName() ); }用主键做 principal 有个额外好处不怕用户名变更。用户名这种字段虽然很少改但只要改一次所有以用户名作为缓存键的授权信息就会全部失效甚至串号。主键方案完全不受影响缓存键稳定排查问题也简单。3.3 第三步重头戏 doGetAuthorizationInfo 的实现授权方法的骨架其实不长Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Long userId (Long) principals.getPrimaryPrincipal(); SetString roles userService.listRoleCodesByUserId(userId); SetString permissions userService.listPermissionCodesByUserId(userId); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.addRoles(roles); info.addStringPermissions(permissions); return info; }就这几行授权的核心逻辑已经完成了。但细节里全是坑展开说三点。第一返回值不能为 null。AuthorizingRealm内部会对授权结果做缓存如果这里返回 null框架会认为用户没有任何授权信息并且因为缓存了空结果用户后面配置了权限也会一直失败。正确做法是哪怕查出来角色和权限都是空也返回一个空的SimpleAuthorizationInfo千万别返回 null。第二查询方法最好按角色和权限分开写。如果你的系统是经典的“用户-角色-权限”三级模型不要只查角色就完事还得把角色映射出的权限查出来通过addStringPermission放进去。反之扁平模型下用户直接绑权限只加权限就行但hasRole这类接口就会永远 false。这取决于你的权限模型没有绝对答案关键是要心里清楚。第三方法体是你可以自由发挥的地方。因为doGetAuthorizationInfo是你自己的代码你完全可以在这里拼接多数据源先查本地库再调权限中心远程接口甚至把动态计算的 ACL 规则加进去。这是自定义 Realm 相比内置 Realm 最大的自由度。3.4 第四步把 Realm 注册进 SecurityManagerRealm 写完之后不注册是不会生效的。Spring Boot 项目里通常写一个配置类把 Realm 和 SecurityManager 定义为 Bean。这里我建议把授权缓存开关显式设置一下避免默认行为变化影响排查Bean public UserRealm userRealm() { UserRealm realm new UserRealm(); realm.setAuthorizationCachingEnabled(true); return realm; } Bean public SecurityManager securityManager(UserRealm userRealm) { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(userRealm); return manager; }如果项目还在用shiro.ini做配置对应写法是这样[main] userRealm com.example.shiro.UserRealm securityManager.realms $userRealm有一类问题特别典型Realm 类写了方法也重写了但权限判断就是不进自己的doGetAuthorizationInfo。排查第一步永远是确认 SecurityManager 里注册了几个 Realm、当前请求命中的到底是哪一个。框架不会自动发现你的自定义类类被 Spring 扫描到了但没注册给 SecurityManager是这种失效最常见的原因。3.5 第五步在业务代码里触发授权判断授权逻辑跑通之后使用姿势一般是三种编程式、注解式、模板标签。编程式最直白适合在代码里做细粒度控制Subject subject SecurityUtils.getSubject(); if (subject.isPermitted(order:export)) { // 执行导出逻辑 } else { // 提示用户无权限 }注解式更适合接口层做声明式拦截RequiresPermissions(order:create) PostMapping(/orders) public Result createOrder(RequestBody OrderDTO dto) { return Result.success(orderService.create(dto)); }注解式依赖 Spring AOP必须确保 Shiro 的注解切面被装配进 Spring 容器。很多人注解写了但一直不生效就是这个切面没装好而不是授权逻辑问题。4. 从硬编码到动态权限数据库模型、缓存刷新与多 Realm 策略4.1 标准 RBAC 五张表怎么拆真实项目的权限来源几乎都是关系数据库。最通用的设计是五张表用户表、角色表、权限表、用户角色关联表、角色权限关联表。权限表里的permission_code字段存放的就是“order:view”这类 Shiro 能直接识别的权限字符串。查某个用户全部权限的 SQL大概长这样SELECT DISTINCT p.permission_code FROM sys_user u JOIN sys_user_role ur ON u.id ur.user_id JOIN sys_role r ON ur.role_id r.id JOIN sys_role_permission rp ON r.id rp.role_id JOIN sys_permission p ON rp.permission_id p.id WHERE u.id ?这段 SQL 当然可以直接在doGetAuthorizationInfo里执行但用户量大、权限数量多的时候每次权限校验都走一遍五表 join数据库压力会很明显。常见的优化思路是让查询走服务层加一层业务自己的缓存Shiro 提供的授权缓存以用户为维度缓存整个AuthorizationInfo能挡住大部分重复查询双重缓存叠加之后性能基本没问题。4.2 权限动态变化时的缓存与刷新策略AuthorizingRealm默认开启授权缓存好处是isPermitted这类高频调用不会每次都回源数据库。坏处也很直接用户的角色一变授权信息还是旧缓存就会出现“管理员刚给用户加了权限用户刷新页面依然没权限”的尴尬局面。处理方式一般两种。第一种在权限变更的业务代码里主动清缓存。比如管理员在后台调整用户角色后立刻对受影响的用户执行userRealm.clearCachedAuthorizationInfo( new SimplePrincipalCollection(userId, userRealm.getName()) );第二种如果你的系统对权限实时性要求极高比如安全审计或者金融场景可以直接关闭授权缓存realm.setAuthorizationCachingEnabled(false);代价是每次isPermitted都会触发一次doGetAuthorizationInfo数据库查询频率瞬间上涨。我的建议是保留缓存在权限变更的关键入口统一刷新比直接关缓存要稳妥得多。另外清缓存需要传PrincipalCollection如果你在认证方法里已经把用户主键作为 principal那构造SimplePrincipalCollection时用同一个主键即可不需要持有 Subject 实例非常方便。4.3 多 Realm 场景下的授权“取并集”问题当项目里配置了多个 Realm比如一个UserRealm管本地账号密码另一个LdapRealm对接外部目录服务默认的ModularRealmAuthorizer是任一 Realm 放行即放行不是所有 Realm 都通过才通过。也就是说只要有一个 Realm 认为用户有某个权限授权就通过。这种“并集逻辑”表面灵活实际很容易出安全问题。最典型的情况是用户本应在某个 Realm 里是普通权限但另一个 Realm 因为配置疏漏返回了宽泛权限导致用户越权访问了核心接口。遇到这种场景要么让每个 Realm 的授权结果足够收敛要么自定义Authorizer改变判定策略。我个人更建议的做法是职责分离认证走多 Realm 轮流尝试授权固定只用一个主数据 Realm这样权限口径统一排查也容易。5. 授权不生效排查实录五个高频坑与定位思路授权这块的问题很多表象相似但根因各不相同。我把这些年遇到的高频问题整理成了一张速查表现象常见根因解决思路注解RequiresPermissions不生效Spring AOP 没装配 Shiro 注解切面检查切面配置确认AuthorizationAttributeSourceAdvisor已注册hasRole一直返回 false授权信息只加了权限没加角色在doGetAuthorizationInfo里同时 addRole权限刚改完但接口仍按旧权限判断授权缓存未刷新调用clearCachedAuthorizationInfo或关闭授权缓存菜单按钮显隐和接口权限不一致角色与权限两套体系口径不统一统一权限来源明确哪一级用角色、哪一级用权限通配权限order:*不匹配order:view:1权限表达式写法或对段数理解有误核对权限字符串格式确认包含关系规则几个细节值得单独拎出来讲。第一个RequiresPermissions注解不生效先别怀疑注解本身。先在 Controller 方法里用编程式subject.isPermitted试一次如果编程式能过、注解式不行那问题基本在 AOP 装配如果编程式也不行再去查 Realm 注册和授权数据来源。这个排查顺序能帮你砍掉一半的无效操作。第二个权限字符串的命名规范要统一。全小写、无空格、域名用单数、操作名用动词比如order:export而不是orders:export或order:export,all。命名一旦混乱前端配权限、后端写注解、数据库存记录各写各的后期维护就是一场灾难。第三个doGetAuthorizationInfo的调用时机。Shiro 对授权信息做了缓存所以并不是每次isPermitted都会调用你的方法。如果你在方法里打了日志却发现权限判断通过了但日志没打那很正常命中的是缓存。反过来如果你改了权限但接口没变化第一反应也应该是去清缓存而不是怀疑代码没生效。第四个获取当前用户信息的姿势。在doGetAuthorizationInfo里通过principals.getPrimaryPrincipal()拿用户标识是最可靠的。有些人习惯直接SecurityUtils.getSubject().getPrincipal()在特殊线程环境下拿到的可能不是当前登录用户一旦用错对象授权信息就张冠李戴。6. 自定义 Realm 授权的设计建议与个人经验最后聊点实践层面的东西。我做了这么多个 Shiro 项目最深刻的体会是授权这块的复杂度从来不在于 Shiro 本身而在于你对权限模型的定义是否清晰。自定义 Realm 只是把数据来源“接”进来了真正决定权限是否好维护的是权限表结构、权限字符串规范、以及缓存刷新策略这三件事。我的建议是项目一开始就定好权限字符串的命名规范并把角色和权限分开管理。角色用于菜单、按钮等粗粒度展示权限字符串用于接口鉴权不要让“角色”这个概念在接口层面到处渗透。下一步就是设计好授权缓存的刷新入口比如做成一个统一的权限变更服务任何角色调整、权限分配都走这个服务顺便完成缓存清理。这样以后不管接多少数据源、扩多少功能授权体系都能稳稳扛住。另外分享一个小技巧写单元测试的时候直接构造一个SimplePrincipalCollection然后调用自定义 Realm 的doGetAuthorizationInfo用断言验证返回的AuthorizationInfo里有没有期望的角色和权限。这一步能帮你把权限数据和 Shiro 框架解耦开测试只要doGetAuthorizationInfo返回正确后面框架层的匹配逻辑就不用你操心了。这个习惯让我在后来几次权限模型重构里省了大量排查时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →