news 2026/10/16 7:59:20

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Apache Shiro自定义Realm授权实践:从认证到权限控制

1. 从认证跨到授权:自定义 Realm 到底解决什么问题

做了几年 Java 后端,接手的大大小小项目里,但凡用到 Shiro,基本都逃不开同一个需求:登录只是第一步,登录之后不同角色能看到什么菜单、能调哪些接口,才是甲方真正关心的东西。认证做得再顺,授权这块如果没捋明白,上线之后就是一堆“为什么他有权限我没有”“为什么按钮点不了”的工单。自定义 Realm 实现授权,要解决的就是“把用户到底有哪些角色、哪些权限这件事,从 Shiro 默认的静态配置里解放出来,交给你自己的数据源和业务逻辑去回答”。这也是几乎所有生产项目绕不开的必经步骤。

1.1 “能登录”和“能做哪些事”之间的鸿沟

聊授权之前,得先把概念对齐。认证解决的是“你是谁”,授权解决的是“你能干什么”。Shiro 的 Subject 对这两件事提供了完全独立的两套 API:登录的时候走doGetAuthenticationInfo,确认账号密码对不对;授权的时候走doGetAuthorizationInfo,返回这个用户完整持有的角色和权限集合,再交给框架去跟你要校验的权限表达式做比对。

我在评审代码时经常看到一种情况:认证方法写得漂亮,登录、记住我、密码加盐全都安排上了,但doGetAuthorizationInfo压根没重写,或者重写了只是打了行日志就返回 null。结果就是用户登录一切正常,hasRole("admin")永远返回 false,checkPermission("order:export")直接抛AuthorizationException,前端拿到 401 一脸懵。这个现象背后的原因无非两种:一是你的 Realm 没实现授权方法,二是实现了但返回的授权信息结构不对。把这两点搞清楚,自定义 Realm 的授权部分就通了。

1.2 内置 Realm 的授权能力边界

Shiro 自带了几种开箱即用的 Realm:IniRealm、PropertiesRealm、JdbcRealm。它们在教学和 demo 项目里确实很香,写一行user/admin=123456,admin就能快速把登录和角色跑起来;JdbcRealm也支持通过三条 SQL(认证查询、角色查询、权限查询)直接从库里捞数据。

但内置 Realm 在真实项目里往往撑不住,原因很直接:权限模型被写死了。JdbcRealm的每条查询 SQL 默认只能返回单列结果,字段名和表结构跟你现有的库对不上就得改 SQL,改完还得小心处理空结果集。权限粒度上,它只能做到“权限字符串”这一层,做不了实例级权限,比如“只看自己创建的订单”“只能编辑自己部门的数据”。更麻烦的是,一旦权限要跨数据源,一部分来自数据库、一部分来自远程权限中心,内置 Realm 基本没有任何扩展余地。

所以生产项目里更常见、也更推荐的做法,是继承AuthorizingRealm,重写两个模板方法,把“查权限、拼角色”这个动作自己扛下来。这就是自定义 Realm 的核心价值:Shiro 把授权引擎和权限匹配算法都封装好了,唯一留给你的入口就是doGetAuthorizationInfo,你只要回答“这个用户能干什么”,框架负责在每次权限校验时自动使用这个答案。

2. 把授权链路看清楚,才知道方法该写在哪个类里

2.1 一次权限检查背后的完整委托链

授权这件事,Shiro 并不是直接调用你的 Realm 去判断“能不能”。它先把请求委托给SecurityManager,SecurityManager再转交给内部的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(); Set<String> roles = userService.listRoleCodesByUserId(userId); Set<String> 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 注册进 SecurityManager

Realm 写完之后不注册是不会生效的。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返回正确,后面框架层的匹配逻辑就不用你操心了。这个习惯让我在后来几次权限模型重构里省了大量排查时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 7:58:51

2S电池主动均衡方案:基于MP2642GR-Z与PIC24FV32KA302的实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 7:57:49

数字人直播App实战:从渲染引擎选型到实时推流全链路拆解

做数字人直播这件事&#xff0c;最早是我在某直播平台看到一个虚拟形象在唱歌带货&#xff0c;第一反应是“这玩意儿不就是预渲染的动画循环播放吗”。真正深入接触之后才发现&#xff0c;实时驱动的数字人直播和传统CG动画根本不是一回事——它需要在一秒30帧甚至60帧的节奏下…

作者头像 李华
网站建设 2026/10/16 7:57:49

Proteus仿真STM32数字万用表:ADC采样与量程切换设计解析

简介&#xff1a;这是一套基于Proteus仿真环境与STM32F103微控制器实现的数字万用表完整工程&#xff0c;适合嵌入式初学者、单片机课程设计及硬件开发人员参考。项目覆盖ADC采集、DAC参考电压生成、LCD显示驱动、按键交互、电源管理及测量算法等多个核心模块&#xff0c;整体方…

作者头像 李华
网站建设 2026/10/16 7:56:39

AI Agent安全防护落地指南:从威胁建模到面试应答

“如果是你&#xff0c;负责一个AI Agent的安全&#xff0c;你会怎么下手&#xff1f;”面试聊到最后一轮&#xff0c;大家都放松了一点&#xff0c;面试官突然抛出这个问题。我见过不少候选人的表现&#xff1a;有开始背越狱绕过套路的&#xff0c;有兴致勃勃讲提示词工程技巧…

作者头像 李华
网站建设 2026/10/16 7:55:49

前端Leader转型AI Agent实战:从零搭建可查资料的Agent系统

1. 一个前端Leader的转型焦虑与破局思路做了八年多前端&#xff0c;带过十几人的团队&#xff0c;从 jQuery 时代一路写到 React Server Components&#xff0c;我原以为自己会一直在这条路上走到黑。直到去年年底&#xff0c;团队开始接一些智能化需求&#xff0c;产品经理张口…

作者头像 李华
网站建设 2026/10/16 7:55:04

拒绝AI流水线:平台真正想要的是有“人味”的内容

1. 一边催你“拥抱AI”&#xff0c;一边偷偷限你的流&#xff1a;平台究竟在拒绝什么大概没有一个创作者没经历过这种精分时刻&#xff1a;平台官方在创作者大会上说得天花乱坠&#xff0c;什么“AI辅助提效”“智能创作工具全面开放”&#xff0c;感觉你不赶紧用AI就是跟不上时…

作者头像 李华