
做权限系统之前我一直跟团队强调一句话别把 RBAC 想复杂了角色、权限在代码里的本质就是字符串。比如“允许新增用户”这个操作落到代码里就是user:add这么一串字符用户能不能执行这个操作就是看他的会话session里面有没有user:add这个字符串。这篇文章我就从这句话出发把 RBAC 权限系统的分析、设计、实现完整过一遍顺便把我在实际项目中踩过的坑、走过的弯路都摊开讲。这套内容适合两类人一类是刚接触权限系统、被各种权限框架绕晕的后端新人另一类是项目里已经堆了很多 if-else 判断权限、想系统性重构的团队。我会按“模型分析 - 会话设计 - 代码落地 - 场景延展 - 排错避坑”的顺序来写全程围绕“字符串 session”这条主线。1. “权限就是一个字符串”RBAC 最容易被忽略的本质1.1 把 RBAC 拆到不能再拆三个模型和三张关系表RBACRole-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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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. 查出该用户全部权限字符串 ListString 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建议存成ListString不要存成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) ListString perms (ListString) 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”这条主线的闭环跑通再根据业务复杂度逐步引入数据权限、动态权限、分布式会话这些进阶能力。最怕的就是一开始就上重型权限框架权限点还没几个先被框架的概念绕晕了。我强烈建议你照着文中的代码从零搭一遍把登录、装载权限、拦截器这三个环节亲手实现一次之后再去看其他权限框架你会发现自己已经能看懂它们的设计思路了。毕竟理解了本质之后任何框架都只是这套字符串模型的另一种封装形式而已。