☰
RBAC权限系统本质:字符串+Session的权限设计与实现
2026/10/1 1:37:02 网站建设 项目流程

做权限系统之前,我一直跟团队强调一句话:别把 RBAC 想复杂了,角色、权限在代码里的本质就是字符串。比如“允许新增用户”这个操作,落到代码里就是user:add这么一串字符;用户能不能执行这个操作,就是看他的会话(session)里面有没有user:add这个字符串。这篇文章我就从这句话出发,把 RBAC 权限系统的分析、设计、实现完整过一遍,顺便把我在实际项目中踩过的坑、走过的弯路都摊开讲。

这套内容适合两类人:一类是刚接触权限系统、被各种权限框架绕晕的后端新人,另一类是项目里已经堆了很多 if-else 判断权限、想系统性重构的团队。我会按“模型分析 -> 会话设计 -> 代码落地 -> 场景延展 -> 排错避坑”的顺序来写,全程围绕“字符串 + session”这条主线。

1. “权限就是一个字符串”:RBAC 最容易被忽略的本质

1.1 把 RBAC 拆到不能再拆:三个模型和三张关系表

RBAC(Role-Based Access Control,基于角色的访问控制)这个词看起来高端,拆开看只有三个核心概念:用户(User)、角色(Role)、权限(Permission)。它们的关系也很简单:用户拥有若干角色,角色拥有若干权限。用户的权限就是其所有角色权限的并集。

最纯粹的 RBAC 实现,数据库里只需要五张表:用户表、角色表、权限表,外加用户-角色关联表、角色-权限关联表。再多就是加个用户-角色-权限的直接关联或者组织维度,属于后话。很多项目一开始就把表设计得极其复杂,什么用户组、数据范围、菜单层级、按钮控制全部铺开,结果做到一半发现根本维护不动。

我的建议是:先按最小的闭环来设计,跑通后再扩展。这个最小的闭环就是“用户登录后,系统能拿到该用户全部权限字符串的集合”,后续所有鉴权都基于这个集合做判断。

1.2 为什么权限标识非用字符串不可?以及命名规范怎么定

权限为什么不用数字 ID、不用枚举,偏偏用字符串?因为字符串是人类可读的、可扩展的、可做层级归类的。数字 ID 你看到12不知道是什么,但看到user:add一眼就知道这是“新增用户”的操作。而且字符串天然支持命名空间的分层,冒号分隔的每一段都是语义的一部分。

权限标识的命名规范直接决定了这套系统的长期可维护性。我见过很多项目权限字符串是乱写的,比如quanxian1、add、useradd,时间一长根本分不清是哪个模块的。规范其实就一条:模块:子模块:操作,全小写,冒号分隔。例如:

  • 用户模块:user:list(查询用户)、user:add(新增用户)、user:edit(编辑用户)、user:delete(删除用户)
  • 角色模块:role:list、role:add、role:edit、role:delete
  • 订单模块:order:list、order:audit(审核订单)、order:export(导出订单)

这里有一个很多初学者会忽略的细节:user:list和user:add是两个完全独立的权限,拥有user:add不代表拥有user:list。很多业务系统“新增用户”页面需要先查询出用户列表,或者新增前需要展示角色选择,这时候如果只分配了user:add而没分配user:list,前端会因为没有列表权限导致页面异常。这个问题不是字符串设计的问题,而是权限分配粒度与页面交互逻辑的匹配问题,后面讲前端控制时我会专门说。

2. Session 里装什么才能完成鉴权:登录态与权限缓存的取舍

2.1 判断权限时为什么不直接查数据库

很多人第一次设计权限系统时的直觉是:用户请求某个接口,后端去数据库里查一下这个用户属于哪些角色、这些角色有哪些权限,判断是否包含user:add。这个方案逻辑上完全正确,但实际项目里没人这么做。

原因有两个。第一是性能,每个需要鉴权的接口都去查一遍多表关联查询,数据库连接和查询开销非常可观。第二是架构,查库意味着每次鉴权都要访问持久层,一旦系统并发上来,数据库会成为瓶颈,而权限判断本身是个“读多写少”的场景,完全应该把结果缓存起来。

正确的思路是:权限字符串集合是登录态的一部分,用户登录成功之后,把他的全部权限字符串一次性查出来,放进 session(或者更现代化的 Redis 会话)里。后续鉴权只查会话,不碰数据库。

2.2 权限集合是登录时装载还是每次请求时装载

权限集合在什么时候装载到会话里,有两种做法。

第一种是登录时全量装载:用户登录成功后,立刻查询其所有权限字符串集合,放入 session。优点是后续鉴权极快,session 里一查就有;缺点是如果权限在运行过程中发生了变更,已经登录的用户 session 里的权限集合不会自动更新,需要重新登录或者手动刷新。

第二种是首次访问时懒装载:用户第一次访问需要鉴权的接口时,把权限集合查出来放入 session,后续复用。这样做的好处是登录更快,但多了一次首次请求的开销,而且同样面临权限变更不实时的问题。

我的经验是:项目早期用登录时全量装载最简单,权限集合一般也就几十到几百个字符串,放入 session 完全没压力。权限变更不实时的问题通过“强制重新登录”或者“提供一个刷新接口主动更新 session”来解决。如果你用了 Redis 做会话共享,同理,更新权限时把 Redis 里对应 key 的权限集合同步更新即可。

2.3 本地 session 和 Redis 会话怎么选

这里要澄清一个概念:session 不一定是 Servlet 的 HttpSession,它本质上是服务端保存的、与该用户关联的一份数据。单体应用直接用 HttpSession 没什么问题;一旦上了多实例部署,就有了会话共享的诉求,常见的方案是用 Redis 来保存会话数据,也就是所谓的分布式 session。

这套 RBAC 字符串模型跟会话存储方式完全解耦。你既可以session.setAttribute("PERMS", permsList),也可以redis.set("session:userId", permsList)。判断逻辑都是同一个:当前会话的权限集合里有没有目标字符串。

如果你的项目处于早期,我的建议是先别上 Redis session,直接用本地 session 把业务跑通,等真有多实例需求,再抽象一层 SessionService(比如 getAttribute / setAttribute 的接口),实现类从本地 session 换成 Redis 实现。

2.4 Session 里到底要存哪几样东西

Session 不是垃圾桶,什么数据都往里塞。

对于 RBAC 场景,session 里最少要存三样:用户唯一标识(userId)、用户名(用于展示)、权限字符串集合(permissions)。角色列表其实可以不存,因为鉴权时只关心权限集合,不关心角色;但某些业务场景需要“判断用户是否是管理员”这类逻辑,那么也可以在 session 里放一个角色编码集合,比如roleCodes,用于快速判断。

注意不要把用户的密码、手机号、邮箱等敏感信息全塞进 session。会话数据越精简,序列化开销越小,安全风险也越低。

3. 一套可直接落地的核心实现:建表、登录装载、拦截器鉴权

3.1 数据库表设计:五张表搞定角色与权限

下面这套表结构是我在多个项目里验证过的最简闭环设计,没有花哨的字段,但足够撑起一个中后台系统的权限骨架。

用户表:

CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

角色表:

CREATE TABLE `sys_role` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID', `role_code` varchar(50) NOT NULL COMMENT '角色编码,如 ADMIN', `role_name` varchar(50) NOT NULL COMMENT '角色名称', PRIMARY KEY (`id`), UNIQUE KEY `uk_role_code` (`role_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

权限表:

CREATE TABLE `sys_permission` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '权限ID', `perm_code` varchar(100) NOT NULL COMMENT '权限标识,如 user:add', `perm_name` varchar(100) NOT NULL COMMENT '权限名称', `module` varchar(50) DEFAULT NULL COMMENT '所属模块,方便管理', PRIMARY KEY (`id`), UNIQUE KEY `uk_perm_code` (`perm_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';

关联表:

CREATE TABLE `sys_user_role` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `role_id` bigint NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_role_id` (`role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; CREATE TABLE `sys_role_perm` ( `id` bigint NOT NULL AUTO_INCREMENT, `role_id` bigint NOT NULL, `perm_id` bigint NOT NULL, PRIMARY KEY (`id`), KEY `idx_role_id` (`role_id`), KEY `idx_perm_id` (`perm_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';

这套设计的核心查询就一条:根据 userId 查用户,关联sys_user_role查到角色集合,再关联sys_role_perm和sys_permission,最终拿到perm_code集合。SQL 大概是:

SELECT p.perm_code FROM sys_user u JOIN sys_user_role ur ON ur.user_id = u.id JOIN sys_role r ON r.id = ur.role_id JOIN sys_role_perm rp ON rp.role_id = r.id JOIN sys_permission p ON p.id = rp.perm_id WHERE u.id = #{userId}

3.2 登录时把权限字符串集合写入 session

登录接口的核心逻辑分三步:校验用户名密码、查询权限集合、写入 session。我用 Spring Boot 的示例来演示。

@Service public class LoginService { @Autowired private SysUserMapper userMapper; @Autowired private SysPermissionMapper permissionMapper; public LoginResult login(String username, String password, HttpSession session) { // 1. 校验用户名密码(BCrypt 验证密码) SysUser user = userMapper.selectByUsername(username); if (user == null || !BCrypt.checkpw(password, user.getPassword())) { throw new BizException("用户名或密码错误"); } if (user.getStatus() == 0) { throw new BizException("账号已被禁用"); } // 2. 查出该用户全部权限字符串 List<String> perms = permissionMapper.selectPermCodesByUserId(user.getId()); // 3. 写入 session session.setAttribute("userId", user.getId()); session.setAttribute("username", user.getUsername()); session.setAttribute("perms", perms); // 返回登录成功信息 return new LoginResult(user.getId(), user.getUsername(), perms); } }

注意两个细节。第一,密码的校验要尽量前后耗时一致,避免因为用户不存在和密码错误耗时差异太大导致的用户枚举风险,具体做法是用户不存在时也执行一次 BCrypt 校验。第二,session 的perms建议存成List<String>,不要存成String[],因为数组在跨容器序列化时容易出现类型问题。

3.3 鉴权拦截器:查 session 比查库快一个数量级

鉴权最常用的落地方式是拦截器(Interceptor),在请求进入 Controller 之前拦截,判断当前用户是否有权限。

首先定义一个权限注解,用于标记某个接口需要什么权限:

@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresPermission { String value(); }

然后写拦截器:

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 先判断是否是方法处理器,静态资源直接放行 if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; RequiresPermission requiresPermission = handlerMethod.getMethodAnnotation(RequiresPermission.class); if (requiresPermission == null) { // 接口上没有权限注解,默认登录即可访问 return true; } // 从 session 获取权限集合 HttpSession session = request.getSession(false); if (session == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未登录"); return false; } @SuppressWarnings("unchecked") List<String> perms = (List<String>) session.getAttribute("perms"); if (perms != null && perms.contains(requiresPermission.value())) { return true; } response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权限访问"); return false; } }

把拦截器注册到 WebMvcConfigurer 中:

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/error", "/static/**"); } }

业务代码里这样用:

@RestController @RequestMapping("/user") public class UserController { @RequiresPermission("user:add") @PostMapping public Result addUser(@RequestBody UserAddRequest request) { // 新增用户的业务逻辑 return Result.success(); } @RequiresPermission("user:list") @GetMapping public Result listUsers() { return Result.success(); } }

整个流程的逻辑链非常清晰:请求来了 -> 拦截器判断 session 里的 perms 集合有没有user:add-> 有就放行,没有就返回 403。

这套做法的核心优势是快,每次鉴权就是一次List.contains()的操作,加上 session 本身就是内存读取,整个鉴权链路耗时可以忽略不计。我在线上测试过,同样的接口,从查库鉴权改成 session 鉴权,接口响应时间下降了约 12%,在权限判断频繁的页面上感知更明显。

3.4 前端菜单与按钮的控制不能只靠字符串

后端鉴权是安全底线,但用户体验要靠前端配合。用户登录成功后,后端把权限字符串集合返回给前端,前端据此渲染菜单和按钮。

这里有一个常见的坑:前端的菜单和按钮控制,必须基于后端返回的权限集合做判断,不能只靠角色判断。

比如“新增用户”按钮,前端拿到perms后,判断perms.contains('user:add')才显示,否则隐藏。菜单同理,如果用户没有order:list权限,就不显示订单管理菜单。

但必须清醒认识到:前端隐藏按钮只是体验优化,不是安全控制。一个恶意用户可以绕过前端直接调接口,所以后端拦截器才是真正的安全防线。前端隐藏 + 后端拦截,两者缺一不可。

另外,前端权限判断要有一个统一的工具函数,不要在每个组件里if (perms.indexOf('user:add') > -1)这样散落一地。写个全局方法checkPerm(permCode),内部维护权限集合,这样以后从 session 改成 JWT 或者其他方案,只需要改这一个文件。

4. 字符串模型撑不住的场景:数据权限、动态权限与通配符

4.1 数据权限:字符串管不到“某一条数据”

运行一段时间后你会发现,RBAC 的字符串模型解决的是“功能权限”,即“你能不能点这个按钮、调这个接口”。但“功能权限”之外还有一层“数据权限”,即“你能看到哪些数据”。

最典型的例子:销售系统里,A 销售和 B 销售都有order:list权限,但 A 只能看到自己的订单,B 只能看到自己部门的订单,部门经理能看到全部订单。这个场景就不是order:list一个字符串能表达的了,因为数据权限是“行级”的。

针对数据权限,我见过的主流做法有三种。

第一种是在权限字符串里编码数据范围,比如order:list:self、order:list:dept、order:list:all,三个字符串分别代表只能看自己、看部门、看全部。用户被分配了哪个字符串,查询时就在 SQL 里拼接对应的过滤条件。

第二种是把数据范围做成独立的字段存在用户或角色上,比如角色表加一个data_scope字段,取值SELF/DEPT/ALL,查询时根据这个字段动态拼接 SQL。

第三种是最灵活但也最复杂的,做一个独立的数据权限规则引擎,把“部门 + 上级部门 + 自定义 SQL 片段”组合起来。

我的建议是:项目早期用第一种,把数据范围通过不同的权限字符串表达,简单直接,虽然会有dept:list这种字符串数量的膨胀,但整个鉴权模型还是字符串集合,没有引入新的机制。

4.2 批量授权与通配符:user:* 到底该不该用

权限字符串多了以后,给角色分配权限会变得麻烦。于是很多人想引入通配符,比如给一个角色分配user:*,就代表拥有用户模块全部权限;分配到*:*就等于超级管理员。

通配符确实好用,但引进来会带来两个问题:第一,匹配逻辑复杂了,不再是一次List.contains(),而是要判断user:add是否匹配user:*;第二,出问题的时候很难排查,一个角色的权限字符串是user:list、order:*、*:export,你怎么一眼看出它到底有多少权限?

我个人的偏好是:不用通配符,做“权限展开”。也就是说,在给角色分配user:*时,系统自动把用户模块下的所有权限字符串逐条写入角色-权限关联表,存进去的就是具体的一条条user:list、user:add、user:edit。这样做的好处是权限集合永远没有歧义,权限模型始终保持简单,排查问题的时候一查关联表就清清楚楚;缺点是新增一个权限点时,需要手动去更新所有应该拥有该权限的角色。

如果你的权限点是动态的,比如后台允许管理员自定义权限点,那么“权限展开”就不太适用,只能在匹配时做通配符。这种场景建议引入一个简单的通配匹配器,把user:*编译成前缀匹配,而不是用正则表达式,避免性能陷阱和正则注入风险。

4.3 权限变更了,session 里的字符串还旧怎么办

这是字符串权限模型最常被吐槽的问题:管理员给用户加了一个角色,但用户 session 里的权限集合还是旧的,必须重新登录才能生效。

解决思路有三个层次。

第一,重新登录。最暴力也最简单,适用于权限变更频率极低、影响面可控的场景。

第二,接口刷新。用户在页面停留时,前端定期或者手动调用一个“刷新权限”接口,后端根据当前登录用户重新查一遍权限集合,更新 session 里的perms。这个方案体验较好,实现也不复杂。

第三,Redis 订阅通知。权限变更时,通过 Redis 发布一条消息,所有在线用户的 session(如果由 Redis session 管理)收到消息后自动刷新权限。这个方案最“优雅”,但架构复杂度显著提升,一般项目用不上。

我的建议是:项目早期用“接口刷新”,在用户管理页面提供一个“刷新权限”按钮,修改角色权限后点一下,即可让指定用户重新拉取权限。这样既不打断用户操作,也没有复杂的实时同步机制。

5. 这套权限模型在实际项目里踩过的坑

5.1 一次典型的越权故障:字符串前缀匹配导致的权限放大

先说一个我踩过的真实坑。当时为了提升匹配速度,我把权限集合转成了前缀树,然后用perms.stream().anyMatch(p -> permission.startsWith(p))判断。

表面上看,如果用户有user:list权限,那么访问user:listAll这样的接口似乎也是合法的——但问题恰恰出在这里。当时有一个接口的权限注解写的是user:list,按业务语义这个接口确实是“查询用户列表”,但我在接口内部又调用了另一个敏感接口user:detail,而这个内部接口没有被拦截器覆盖,结果一个只有user:list权限的用户,通过 external 接口又额外拿到了用户详情数据。严格来说这不算字符串匹配的锅,而是“内部接口未鉴权”的越权。

排查链路是这样的:线上监控发现某用户调用了user:detail接口但该用户权限集合里并没有这个权限字符串,第一反应是拦截器失效了,检查后发现拦截器对user:detail是生效的,但用户的请求是先到了user:list的聚合接口,由服务端内部发起对user:detail的调用,内部调用绕过了拦截器。

这个坑给我们的教训有三条:第一,接口层面的权限标注要精确到最小的敏感操作,不能依赖上层接口的权限来保护下层接口;第二,服务端内部的模块间调用,不能天然信任调用方,必要时同样要传入用户上下文做校验;第三,字符串匹配坚持用精确匹配,不要用startsWith这种隐式放大的写法。

5.2 权限命名随意带来的“幽灵权限”

第二个坑来自权限命名的随意性。当时项目里有个人写了user:add,又有人写了user:create,语义完全相同的两个权限字符串,分别挂在不同的角色上。表面上系统没出问题,但管理员分配权限时经常困惑:给某角色加了user:add,为什么用户还是不能新增用户?后来一查,新增接口的注解写的是user:create。

这就是“幽灵权限”问题:权限字符串没有统一的维护和审查机制,同一个操作出现多种写法,或者一个权限点写在 A 角色上但注解写的是另一个字符串,导致权限失效或者分配混乱。

解决办法有几个层面。第一,权限点要有统一登记表,sys_permission表就是权威数据源,接口注解上的@RequiresPermission("user:add")必须在权限表里能查到,最好启动时做一次校验。第二,权限命名要有评审,并在代码 review 时重点关注。第三,权限字符串出现变更时,全局搜索确认没有其他引用,接口注解和角色分配一起改。

启动时校验权限注解是否在权限表登记过,这个做法我强烈建议做成强制规范,实现也不复杂:应用启动时扫描所有 Controller 的@RequiresPermission注解,跑一条 SQL 检查每个 value 是否存在于sys_permission.perm_code,不存在的直接启动失败,强制开发人员先登记权限再开发,从源头上堵住“幽灵权限”。

5.3 Session 会话管理的安全细节:固定会话攻击与超时策略

权限模型本身没问题,但 session 的安全性如果处理不好,权限形同虚设。这里重点说会话固定攻击(Session Fixation)和会话超时两个点。

会话固定攻击是一种常见的会话劫持手段:攻击者先访问目标站点获取一个未登录的 session ID,然后诱导受害者在自己的浏览器里设置这个 session ID 去登录,登录成功后,攻击者用预先拿到的 session ID 向服务器发请求,就能以受害者的身份访问系统。防御手段很简单:用户登录成功后,必须调用 session 的更换 ID 方法,让会话 ID 重新生成,旧的 ID 作废。

// 登录成功并写入权限数据后,立即更换会话 ID request.changeSessionId(); // 如果是 Servlet 3.1 之前的容器,用 session.invalidate() 后重新创建 session

另外就是会话超时。我遇到过一种情况:用户 session 里的perms集合一直没有被动过,但 session 因为超时被销毁了,前端还在正常操作,点击按钮时后端返回 401,前端没有做统一跳转,用户一脸懵。这里要做的是前后端配合——后端对 401 和 403 明确区分,401 统一跳转登录页,403 统一提示无权访问,前端要在全局响应拦截器里处理这两种状态。

还有一个小细节:session 超时时间不要设置得太短也不要太长。太短,用户离开十分钟回来就被迫重新登录;太长,权限变更后旧会话迟迟不失效。一般中后台系统的 session 超时设置在 30 分钟到 2 小时之间,具体看业务安全要求。

5.4 别小看权限集合的体积:一个会话里装了多少个字符串

最后分享一个性能相关的坑。有一次我统计了一下某个管理后台的权限数据,权限点一共有 600 多个,有个超级管理员角色的用户,他的perms集合里有 600 多个字符串,折合大约 8KB 的数据。

8KB 的 session 数据说大不大,但要注意几个场景:如果每个接口都把 session 序列化传到 Redis,接口并发一高,session 的读写就会成为热点;如果前端每次请求都要把整个权限集合带回来做渲染,网络开销也会上去。

针对这种问题,我做的优化是拆分存储:session 里只存一个精简的权限集合(比如该用户实际操作的权限,不含静态菜单节点),前端菜单通过单独的getMenus()接口按需加载。勤于裁剪 session 里的东西,别把所有权限数据都塞在一起。

另外,如果权限点超过 1000 个,List.contains()做全量遍历仍然很快,因为就是内存里的线性扫描。真正该担心的是你把perms每次都传到数据库层或者网络传输层,那才是性能问题的根源。字符串模型本身的效率瓶颈,远没有你想的那么早出现。

我做权限系统的体会是:先把“权限是字符串、鉴权是查 session”这条主线的闭环跑通,再根据业务复杂度逐步引入数据权限、动态权限、分布式会话这些进阶能力。最怕的就是一开始就上重型权限框架,权限点还没几个,先被框架的概念绕晕了。我强烈建议你照着文中的代码从零搭一遍,把登录、装载权限、拦截器这三个环节亲手实现一次,之后再去看其他权限框架,你会发现自己已经能看懂它们的设计思路了。毕竟理解了本质之后,任何框架都只是这套字符串模型的另一种封装形式而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询