Java Shiro自定义Realm实现授权:从原理到权限数据模型实践 Java Shiro 安全框架十自定义 Realm 实现授权做了几个 Shiro 项目之后我发现很多同学对“登录成功”这件事特别上心但对“登录之后能干什么”往往只有一个模糊概念反正登录进去了页面上的按钮该不该显示、接口该不该让调、数据该不该让看这些才是一个安全框架真正值钱的地方。Shiro 的授权Authorization能力虽然接口简单但要把权限数据从数据库里捞出来、装进框架能识别的结构、再让注解和标签正常工作中间的关键节点就是“自定义 Realm”。这篇文章我打算只讲授权这一半。默认你已经跑通了认证Authentication知道 Realm 是 Shiro 连接数据源的那座桥。我们顺着这座桥继续往前走把doGetAuthorizationInfo这个方法的里里外外都拆一遍从数据模型设计、代码实现、调试验证到生产环境最容易踩的缓存和权限串号问题一次说清楚。1. 自定义 Realm 在 Shiro 授权中的定位1.1 授权和认证是两套独立的动作很多人第一次接触 Shiro 时会以为“登录数据都从数据库查了顺手把权限也查出来不就行了”。逻辑上没问题但 Shiro 的设计把这两件事拆得很干净认证回答“你是谁”授权回答“你能干什么”。这两件事在代码层面分别对应 Realm 里的两个方法——doGetAuthenticationInfo和doGetAuthorizationInfo。之所以非要拆开是因为授权判断发生的时机远比登录时多。用户登录时你当然可以查出他所有角色权限并塞进 Session但真实系统里权限经常变化管理员给用户加了个角色、用户被移出了某个部门、权限菜单被调整。如果只在登录时查一次那这些变更只能等用户重新登录才生效太不灵活。Shiro 的做法是每次执行subject.isPermitted()或subject.hasRole()时动态向 Realm 要一次授权数据再配合缓存把性能问题解决掉。所以自定义 Realm 的授权部分本质上就是实现一个“按用户查角色和权限”的查询器。你的业务逻辑再复杂最终都得收敛到给它返回一个AuthorizationInfo。1.2 内置 Realm 的授权能力为什么不够用Shiro 自带了好几款开箱即用的 Realm比如IniRealm、PropertiesRealm、JdbcRealm。其中JdbcRealm最容易被拿来直接对接数据库它默认的权限查询 SQL 是这样的select role_name from user_roles where username ?以及select permission from roles_permissions where role_name ?这套表结构设计的初衷是“一个用户直接关联角色角色直接关联权限”简单直接。但真实业务表很少长这样常见情况是用户表、角色表、用户角色关联表、权限表、角色权限关联表五张表起步而且用户名可能是邮箱、手机号或企业内部工号。你要是为了凑JdbcRealm的默认 SQL 去改业务表结构那纯粹是本末倒置。更关键的是JdbcRealm默认权限模型过于扁平它无法表达层级权限也没法让你在权限数据里塞入自定义的过滤条件。所以项目的通用做法是把AuthorizingRealm拿过来继承自己写查询逻辑。这就是“自定义 Realm 实现授权”这个主题存在的根本原因。1.3 一条权限判断背后的完整调用链我习惯在讲代码前先让读者脑子里有一张调用图。你写业务代码时只调了一句if (subject.isPermitted(order:export)) { // 允许导出订单 }这一句话背后发生了什么完整链路大致是这样Subject委托给SecurityManager。SecurityManager继续传给Authorizer默认实现是ModularRealmAuthorizer。ModularRealmAuthorizer遍历当前 SecurityManager 里注册的所有 Realm逐个询问realm.isPermitted(principal, permission)。每个 Realm 内部会先检查缓存没有缓存就调用doGetAuthorizationInfo(principalCollection)加载授权数据。授权数据拿到后和当前要判断的Permission做匹配返回 boolean。有意思的是第三步ModularRealmAuthorizer会“遍历所有 Realm 并一票制决定结果”。这是什么意思只要有一个 Realm 说“允许”整个判断结果就是允许。这意味着如果你同时配置了多个 Realm哪怕某个 Realm 里压根查不到这个用户的权限只要另一个 Realm 查到了授权请求依然能通过。这个小细节在生产环境里会带来一些诡异现象后面我会专门讲。2. 从 AuthorizingRealm 切入授权的骨架2.1 doGetAuthorizationInfo 是唯一的核心入口AuthorizingRealm这个抽象类帮我们做了大量模板化工作。它继承自AuthenticatingRealm在认证能力基础上扩展了授权能力。我们要实现的方法只有一个protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals)参数PrincipalCollection里装着用户身份标识。别小看这个参数它可能包含多个 Realm 各自返回的 principal所以你在取用户标识时不能想当然用principals.getPrimaryPrincipal()要看你认证时SimpleAuthenticationInfo里到底放的是什么对象。返回的AuthorizationInfo就是授权数据的容器最常用的实现类是SimpleAuthorizationInfo它内部维护了三类数据方法作用常见用法addRole(String)添加单个角色标识info.addRole(admin)addRoles(SetString)批量添加角色从数据库查询结果直接放入addStringPermission(String)添加字符串权限info.addStringPermission(order:export)addObjectPermission(Permission)添加对象权限自定义 Permission 实现时使用很多初学者会纠结“角色和权限到底往哪个里面放”。我的建议是角色照常放权限也照常放两者并不冲突。角色用于粗粒度的功能入口控制权限用于细粒度的操作控制后面会展开讲。2.2 授权数据该从哪里来doGetAuthorizationInfo方法体里面一般就三步拿用户标识、查数据库、组装AuthorizationInfo。真正的业务复杂点全在第二步。这里我要强调一个容易被忽视的设计问题授权查询到底应该按用户主键查还是按用户名查如果认证时你往 principal 里塞的是用户主键userId那授权查询就用userId做条件。如果塞的是用户名就按用户名查。两种都可以但我更推荐主键因为用户名可能被修改而且即使两个用户同一天注册、同名主键也不会撞。唯一的注意点是Subject的 API 大多接收 principal 作为参数你从principals.getPrimaryPrincipal()取出来的类型必须和你放进去时一致否则要做类型判断再转换。至于查询结果我也顺手说一个实践不要在doGetAuthorizationInfo里一次只查角色另一次只查权限最好一条查询把用户的角色码列表查出来再用角色集合去查权限集合。这样可以减少数据库交互次数。很多同学在缓存没生效的开发阶段会反复看到 SQL 日志里授权查询刷屏通常就是这里多查了几趟。2.3 授权缓存Shiro 替你省了多少事AuthorizingRealm内置了授权缓存。默认情况下缓存是关闭的你需要设置authorizationCachingEnabled true并提供一个CacheManager。如果什么都不配每一次isPermitted()判断都会真实触发doGetAuthorizationInfo查询数据库性能上是灾难。这里的坑在于缓存默认 key 是AuthorizationCacheKey基于 principal 计算。什么意思呢用户 A 的权限被缓存后如果管理员修改了 A 的权限再判断A.isPermitted(...)时 Shiro 不会主动知道你数据库变了它只会从缓存里取出旧数据。这就是“权限变更后不生效”的经典问题。解决思路有两个一是使用像 Redis 这样的分布式缓存并主动清理 key二是实现一个带版本号的缓存策略。最省事的办法是在权限变更的业务代码里主动调用realm.getAuthorizationCache().remove(userId);或者通过SecurityUtils.getSubject().logout()让用户重新登录。方式很多没有银弹但你必须知道这个机制存在否则线上会出现“为什么我改了权限他还能访问”的诡异工单。3. 手写一个支持权限的 Realm 授权实现3.1 一个够用的权限数据模型先约定一套简单但真实的数据表用户表sys_user、角色表sys_role、权限表sys_permission、用户角色关系表sys_user_role、角色权限关系表sys_role_permission。权限表里的perm_code字段我强烈建议采用 Shiro 通配符风格的字符串形如order:list order:detail order:create order:export system:user:resetPwd这种写法不是 Shiro 强制要求的但配合它默认的WildcardPermission做字符串匹配时非常自然。order:*能匹配所有 order 下面的权限*:export能匹配所有模块的导出操作写出类似“所有模块可查看”的规则也容易。角色表里的role_code则采用业务语义的标识比如admin、operator、auditor。我还习惯加一个role_type字段区分“系统内置角色”和“自定义角色”授权查询时按需过滤。3.2 核心代码doGetAuthorizationInfo 的完整实现下面这个UserRealm示例包含授权部分。为了演示清晰我假设认证时 principal 放的是SysUser对象该对象有getId()返回用户主键。public class UserRealm extends AuthorizingRealm { private UserService userService; private PermissionService permissionService; // 构造方法注入省略 Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 1. 获取当前登录用户 Object primary principals.getPrimaryPrincipal(); if (primary null) { return null; } SysUser user; if (primary instanceof SysUser) { user (SysUser) primary; } else { // 如果统一存的是 username需要反查用户 user userService.findByUsername(String.valueOf(primary)); } if (user null) { return null; } // 2. 查角色集合和权限集合 SetString roleCodes roleService.findRoleCodesByUserId(user.getId()); SetString permCodes permissionService.findPermCodesByUserId(user.getId()); // 3. 组装授权信息 SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.setRoles(roleCodes); info.setStringPermissions(permCodes); return info; } Override public String getName() { return userRealm; } }需要注意两个小点第一principals.getPrimaryPrincipal()返回的类型完全取决于认证时SimpleAuthenticationInfo的第一个参数传的是什么所以上面代码做了类型兼容处理。第二getName()方法在多个 Realm 共存时要保证唯一因为 Shiro 可能用它区分不同的 Realm 缓存。3.3 权限字符串的匹配逻辑到底是怎样工作的Shiro 的WildcardPermission把权限字符串按冒号分成多段每段内可以用逗号表示“或”用*表示“任意”。比如order:list只匹配 order 模块的 list 操作。order:list,detail匹配 order 模块的 list 和 detail 两个操作。order:*匹配 order 模块下所有操作。*:export匹配所有模块的 export 操作。用户拥有的权限字符串集合和判断时传入的权限字符串做匹配时规则是“用户权限要能覆盖请求权限”。比如用户拥有order:*他请求order:export匹配成功他请求product:export匹配失败因为用户权限只有 order 段product 段完全不沾边。这里有一个很常见的误区有人会把“拥有 order:* 就以为能操作所有模块”。记住*只在它所在的段里通配不会跨段。想要全局权限得写*:*:*或直接给用户*。如果默认的冒号多段规则满足不了业务你可以自定义Permission实现类再通过info.addObjectPermission(...)放进去。自定义匹配逻辑的判断入口是Permission.implies(Permission p)方法这里可以完全按业务规则来。不过绝大多数项目用默认通配符已经足够我建议不要过度设计。3.4 把 Realm 注册进 Shiro 配置现在把自定义 Realm 配置到 Spring Boot 项目中。我这里给出一个基于配置类的写法这也是目前主流工程常用的方式Configuration public class ShiroConfig { Bean public UserRealm userRealm() { UserRealm realm new UserRealm(); realm.setCredentialsMatcher(hashedCredentialsMatcher()); realm.setAuthorizationCachingEnabled(true); realm.setAuthorizationCacheName(authorizationCache); return realm; } Bean public DefaultWebSecurityManager securityManager(UserRealm realm) { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(realm); return manager; } }这里我特意把setAuthorizationCachingEnabled(true)和缓存名称写出来因为很多人 Spring Boot 集成 Shiro 时忘了开启缓存导致授权查询高频率打到数据库。开启后配合 Redis 缓存可以大幅提升权限判断性能但要注意缓存失效问题这一点我在后面第 5 部分详细说。4. 授权生效后的验证与调试4.1 用单元测试验证授权结果授权逻辑写完后第一步不是启动整个 Web 应用去点按钮而是写几个快速验证用例。Shiro 提供了一套线程绑定 Subject 的机制在测试代码里可以直接模拟一个带权限的用户public class UserRealmAuthorizeTest { Test public void testAdminCanExportOrder() { // 组装授权信息用户拥有 order:* 权限 SimpleAuthorizationInfo authInfo new SimpleAuthorizationInfo(); authInfo.addRole(admin); authInfo.addStringPermission(order:*); // 构造一个绑定 Subject 的 SecurityManager Realm realm mockRealmWithAuthInfo(authInfo); // 使用 Shiro 提供的测试工具认证一个假用户 Subject subject new Subject.Builder(securityManager).buildSubject(); subject.login(new UsernamePasswordToken(tester, 123456)); // 断言权限判断结果 assertTrue(subject.isPermitted(order:export)); assertFalse(subject.isPermitted(product:delete)); } }这种测试的价值在于它不依赖数据库直接验证 Shiro 匹配逻辑是否符合预期。如果你改了权限字符串格式跑一遍测试马上知道有没有破坏旧规则。注意mockRealmWithAuthInfo是测试辅助方法大概思路是自定义一个 Realm 把authInfo固定返回这样doGetAuthorizationInfo就不会访问数据库。4.2 调试时如何观察授权数据是否加载实际遇到“某个接口明明配置了权限注解却 401/403”时最痛苦的是不知道“用户到底有没有这个权限”。我的习惯是先开 DEBUG 日志观察LoggingSubject或自定义拦截器打印的 principal 和权限字符串。具体操作上可以在doGetAuthorizationInfo临时打印一下查出来的角色和权限集合log.debug(用户 {} 的角色: {}, user.getUsername(), roleCodes); log.debug(用户 {} 的权限: {}, user.getUsername(), permCodes);然后调用权限判断的地方也打印一下请求权限log.debug(判断权限: {}, permission);两端日志一对照问题大概率立刻暴露要么是权限没查到要么是权限字符串格式不对。举个例子数据库权限表里存的是order:export代码里注解写的是RequiresPermissions(order:export)理论上没问题但如果权限表里存成了order_export或者export那永远匹配不上。这种问题靠眼睛看数据库就能快速定位。4.3 授权在三个业务层的落地姿势Realm 提供的权限数据是地基但最终要作用到业务的三个位置第一层是 URL 过滤。在ShiroFilterFactoryBean里配置filterChainDefinitionMap比如/order/** perms[order:list]请求没经过 URL 层就拦截掉。这层适合做粗粒度模块控制。第二层是方法注解。用RequiresRoles(admin)、RequiresPermissions(order:export)标记在 Controller 方法或 Service 方法上适合做细粒度操作控制。注意注解默认是 AOP 拦截工作需要配置AuthorizationAttributeSourceAdvisor才能生效。第三层是页面标签。JSP 环境下可以用shiro:hasPermission nameorder:exportVue 这类前后端分离项目则往往由后端接口返回权限码列表前端自己控制按钮显示。我见过很多项目在 URL 层和注解层重复配置同一套权限导致一条规则要改两处。我的经验是模块级规则放 URL 层操作级规则放注解层不要重复拦截。权限码列表通过接口返回给前端这样三层的权限来源统一不至于各写一套。5. 生产环境中授权相关的常见坑与解法5.1 权限变更后用户权限不更新的问题这是授权功能上线后最常遇到的投诉“管理员给张三加了导出权限张三刷新页面还是看不到导出按钮”。原因前面说过AuthorizingRealm开了缓存后授权数据被快照在缓存里数据库变更不会自动推送给缓存。处理方案要看你的缓存介质。如果用本地EhCache只能在权限变更时调realm.clearCachedAuthorizationInfo(principals)但要拿到用户对应的 principals 需要额外构造。如果用 Redis推荐在权限变更服务里主动删除对应用户的 cache keykey 的命名规则要和你securityManager.setCacheManager(...)配置的序列化策略一致。我实际项目中更倾向的方案是授权缓存不用 Shiro 自带的 CacheManager而是自己封装一层回调。权限变更后通过消息队列广播一个用户 ID所有节点都删除该用户授权缓存。这样既保证了多实例环境的一致性也不至于为了清缓存引入复杂的分布式锁。5.2 多个 Realm 协同工作时的授权合并陷阱有些系统会配置多个 Realm比如一个UserRealm处理账号密码登录一个TokenRealm处理免登令牌。此时ModularRealmAuthorizer的默认行为是任何一个 Realm 的授权数据命中即算通过。这带来的一个奇怪现象是用户能登录但判权限时 Shiro 同时问两个 Realm 要授权数据。UserRealm里查不到角色的用户请求subject.hasRole(admin)时TokenRealm如果也实现了doGetAuthorizationInfo并且返回了 admin 角色那这个用户居然也能算作管理员。要处理这种隐患建议在doGetAuthorizationInfo里约束数据的适用范围。最简单的方式是没有用户数据的 Realm 直接返回null或空AuthorizationInfo不要含糊地放一个空集合。另一个方式是重写ModularRealmAuthorizer的核心逻辑按 principal 来源区分我只认某个 realm 的数据。不过这个方案配置成本高大多数业务场景用“不越权返回数据”就能规避。5.3 授权查询频繁打到数据库权限判断本身是高频操作。登录用户每次刷新页面可能要触发几十次isPermitted()如果你忘了开缓存每一次都会进doGetAuthorizationInfo。运气好命中慢查询运气不好就是连接池打满。排查方法很简单打开数据库慢查询日志观察授权 SQL 的执行频次。解决路径分两步第一步开启authorizationCachingEnabled第二步是给doGetAuthorizationInfo里的查询加索引。sys_user_role和sys_role_permission两张关联表userId、roleId 字段必须有索引否则数据量上来必炸。5.4 权限字符串因大小写和空格匹配不上数据库里权限码不规范是家常便饭。有人存的是Order:Export有人复制时多了个空格Shiro 默认的WildcardPermission做的是精确字符串拆分匹配大小写不同都算不匹配。想从源头杜绝必须在权限维护页面做输入清洗统一转小写、去掉两侧空格、限制只能使用字母数字冒号星号逗号。同时在doGetAuthorizationInfo里也做一次trim().toLowerCase()兜底。不要指望靠配置 Shiro 的WildcardPermission来做大小写忽略它没有这个开关。6. 权限设计层面的一些建议6.1 权限点细到操作角色粗到入口做完技术实现我更想聊聊权限模型设计这部分经验在很多项目里比代码更值钱。我见过两种极端一种是把权限细到“看见订单金额”这种字段级结果是权限表爆炸维护成本极高另一种是只有三四套角色一个“管理员”走天下结果业务根本没法精细管控。平衡点通常是把权限点设计到“模块操作”粒度比如order:export、user:resetPwd至于字段级的数据权限应该通过查询条件动态过滤而不是塞进权限框架。角色则尽量保持在个位数级别比如系统管理员、普通用户、审计员、外部合作方。角色和权限之间是组合关系角色代表一种可授权的行为集合不要在系统里搞出几百个组合角色。6.2 不要在业务代码里硬编码权限判断虽然 Shiro 提供了subject.isPermitted()这种代码级判断但我不建议在业务方法里到处写 if 硬编码。硬编码权限逻辑散落在各业务层等权限规则调整时你会陷入改代码的噩梦。我更推荐的做法是接口上用注解声明所需权限统一由 AOP 拦截特殊场景比如同一个接口针对“本人”和“管理员”返回不同数据才在 Service 里显式判断并封装一个PermissionChecker工具类避免直接和 Subject API 耦合。这样权限需求变更时大多数情况只需要调整数据库权限配置不需要重新发版。6.3 从最小可用权限模型开始迭代最后一条建议给刚开始接触 Shiro 的团队不要上来就设计五张表加一套复杂的动态权限体系。先按“用户-角色”二级模型跑通授权链路把doGetAuthorizationInfo的骨架搭好等业务真的出现“操作级精细管控”需求再加权限表、角色权限关联表。安全框架的价值在于稳定可靠地拦住不该放行的人而不是一开始就堆功能。我的经验是框架本身的灵活度足够支撑从简单到复杂的演进。你先把授权通了后面加权限点只是往数据库里插几行数据的事完全不用改架构。这个系列走到第十篇自定义 Realm 的授权部分算是彻底讲透了。从定位到实现再到排坑该注意的地方都标注了。如果你正在改造老项目的权限模块建议先按第 5 部分的几个坑对照一下现有代码往往能少熬好几个夜。