权限系统设计复盘——从RBAC模型到JWT双Token,方法级权限控制的完整实现 前言在简历上我写了启航学习俱乐部这个项目——一个从0到1独立开发的后台管理系统。其中权限控制是核心模块涉及RBAC权限模型、Spring AOP方法级鉴权、JWTRedis双Token认证。面试中权限系统是考察设计能力的经典题目。面试官会追着问“RBAC的用户-角色-权限三级关系是怎么设计的”“你的自定义注解是怎么实现方法级权限控制的”“AccessToken和RefreshToken各存什么为什么分开”“Token过期了怎么办自动续期是怎么做的”本文完整复盘这个权限系统的设计思路和实现细节。本文核心问题RBAC权限模型是什么用户-角色-权限三级关系怎么设计数据库表结构怎么设计用户表、角色表、权限表、关联表怎么关联Spring AOP 自定义注解怎么实现方法级动态权限校验JWT单Token有什么安全问题为什么需要双TokenAccessToken和RefreshToken各自存什么过期时间怎么定Redis在这里做了什么黑名单和自动续期怎么实现整个认证-鉴权-刷新的完整流程是怎样的读完本文你将对权限系统的设计拥有从数据库到接口的完整理解。一、RBAC权限模型——用户-角色-权限三级关系疑问什么是RBAC为什么不直接把权限赋给用户回答RBAC全称Role-Based Access Control核心思想是用户→角色→权限的间接关联。如果直接把权限赋给用户当系统有1000个用户、每人需要调整权限时需要逐个用户修改维护成本线性增长。而RBAC中用户只和角色关联修改一个角色的权限所有拥有该角色的用户同步生效。1.1 三级关系图┌──────────┐ ┌──────────────┐ ┌──────────┐ │ 用户 │────→│ 用户-角色关联 │←────│ 角色 │ │ User │ │ user_role │ │ Role │ └──────────┘ └──────────────┘ └────┬─────┘ │ ┌────▼─────┐ │ 角色-权限关联│ │ role_perm │ └────┬─────┘ │ ┌────▼─────┐ │ 权限 │ │Permission │ └──────────┘1.2 五张核心表表名作用核心字段sys_user用户表user_id, username, password, statussys_role角色表role_id, role_name, role_codesys_permission权限表perm_id, perm_name, perm_code如user:deletesys_user_role用户-角色关联user_id, role_idsys_role_permission角色-权限关联role_id, perm_id1.3 权限编码规范我在项目中使用模块:操作的命名方式定义权限码权限编码含义user:list查看用户列表user:add新增用户user:update修改用户user:delete删除用户course:list查看课程列表course:manage管理课程上架/下架等asset:list查看资产列表asset:export导出资产报表设计原则权限码是权限控制的唯一标识和前端路由、后端接口一一对应。新增功能只需增加对应的权限码不需要改代码逻辑。二、数据库表结构设计-- 用户表CREATETABLEsys_user(user_idBIGINTPRIMARYKEYAUTO_INCREMENT,usernameVARCHAR(50)NOTNULLUNIQUE,passwordVARCHAR(255)NOTNULL,-- BCrypt加密emailVARCHAR(100),statusTINYINTDEFAULT1,-- 1:启用 0:禁用create_timeDATETIMEDEFAULTCURRENT_TIMESTAMP);-- 角色表CREATETABLEsys_role(role_idBIGINTPRIMARYKEYAUTO_INCREMENT,role_nameVARCHAR(50)NOTNULL,role_codeVARCHAR(50)NOTNULLUNIQUE,-- 如 ADMIN, TEACHER, STUDENTdescriptionVARCHAR(200),create_timeDATETIMEDEFAULTCURRENT_TIMESTAMP);-- 权限表CREATETABLEsys_permission(perm_idBIGINTPRIMARYKEYAUTO_INCREMENT,perm_nameVARCHAR(50)NOTNULL,perm_codeVARCHAR(100)NOTNULLUNIQUE,-- 如 user:deleteparent_idBIGINTDEFAULT0,-- 父权限ID用于菜单树perm_typeVARCHAR(20),-- MENU/BUTTON/APIcreate_timeDATETIMEDEFAULTCURRENT_TIMESTAMP);-- 用户-角色关联表CREATETABLEsys_user_role(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,role_idBIGINTNOTNULL,UNIQUEKEYuk_user_role(user_id,role_id));-- 角色-权限关联表CREATETABLEsys_role_permission(idBIGINTPRIMARYKEYAUTO_INCREMENT,role_idBIGINTNOTNULL,perm_idBIGINTNOTNULL,UNIQUEKEYuk_role_perm(role_id,perm_id));设计要点关联表使用联合唯一索引防止重复关联权限表设parent_id支持树形结构可扩展为菜单树用户密码使用BCrypt加密存储不保存明文三、Spring AOP 自定义注解——方法级权限控制疑问怎么在接口上声明这个接口需要什么权限如何校验当前用户是否有这个权限回答用自定义注解声明权限要求用Spring AOP拦截方法调用并校验。3.1 自定义注解Target(ElementType.METHOD)Retention(RetentionPolicy.RUNTIME)publicinterfaceRequirePermission{Stringvalue();// 权限码如 user:delete}3.2 在Controller上使用RestControllerRequestMapping(/api/user)publicclassUserController{GetMapping(/list)RequirePermission(user:list)// 需要 user:list 权限publicResultlistUsers(){...}PostMapping(/add)RequirePermission(user:add)publicResultaddUser(RequestBodyUserReqreq){...}DeleteMapping(/{id})RequirePermission(user:delete)// 只有拥有此权限的用户才能调用publicResultdeleteUser(PathVariableLongid){...}}3.3 AOP切面实现权限校验AspectComponentpublicclassPermissionAspect{AutowiredprivatePermissionServicepermissionService;Around(annotation(requirePermission))publicObjectcheckPermission(ProceedingJoinPointjoinPoint,RequirePermissionrequirePermission)throwsThrowable{// 1. 从Spring Security上下文获取当前用户UsercurrentUserSecurityContextHolder.getContext().getUser();// 2. 获取当前用户的所有权限码SetStringuserPermissionspermissionService.getPermissionCodesByUserId(currentUser.getUserId());// 3. 检查是否拥有所需权限StringrequiredPermrequirePermission.value();if(!userPermissions.contains(requiredPerm)){thrownewAccessDeniedException(权限不足: requiredPerm);}// 4. 权限校验通过执行原方法returnjoinPoint.proceed();}}3.4 权限查询的SQL-- 查询某个用户的所有权限码SELECTDISTINCTp.perm_codeFROMsys_permission pJOINsys_role_permission rpONp.perm_idrp.perm_idJOINsys_user_role urONrp.role_idur.role_idWHEREur.user_id#{userId};整个流程用户请求→AOP拦截→从Redis/DB获取用户权限集合→比对注解中的权限码→通过则放行否则抛异常。全局异常处理器捕获AccessDeniedException返回统一的权限不足响应。四、JWT双Token——AccessToken RefreshToken疑问单Token有什么问题为什么需要双Token回答单Token面临安全性和用户体验的矛盾——Token有效期长不安全有效期短用户频繁重新登录体验差。双Token通过短期AccessToken长期RefreshToken解决了这个矛盾。4.1 单Token的困境方案问题Token有效期长如7天Token泄露后攻击者可长时间使用无法主动失效Token有效期短如15分钟用户频繁重新登录体验极差4.2 双Token分工Token类型存储位置有效期作用是否可主动失效AccessToken客户端内存15分钟携带用户信息访问API否无状态签发后即独立存在RefreshTokenRedis7天获取新的AccessToken是删除Redis中的Key即可4.3 双Token的完整流程┌─────────────────────────────────────────────────────────────┐ │ 用户登录 │ │ 输入用户名密码 → 验证 → 生成AccessToken RefreshToken │ │ AccessToken返回给客户端 → RefreshToken存入Redis │ └──────────────────────────┬──────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────┐ │ 正常请求 │ │ 客户端携带AccessToken → 网关校验 → 解析用户信息 → 放行 │ │ AccessToken快过期 → 客户端用RefreshToken请求刷新 │ └──────────────────────────┬──────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────┐ │ Token刷新 │ │ 客户端发送RefreshToken → 校验Redis中是否存在 │ │ 存在 → 生成新AccessToken 新RefreshToken → 旧Token失效 │ │ 不存在 → 用户需重新登录 │ └──────────────────────────┬──────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────┐ │ 退出登录 │ │ 删除Redis中的RefreshToken → AccessToken自动过期后失效 │ │ 如需立即失效 → 将AccessToken加入Redis黑名单 │ └─────────────────────────────────────────────────────────────┘4.4 为什么要用Redis存RefreshToken理由说明主动失效用户修改密码、管理员冻结账号后直接删除Redis中的RefreshToken该用户立即无法续期退出登录用户退出时删除RefreshToken即使AccessToken未过期过期后也无法重新续期黑名单AccessToken无状态签发后已独立存在。如需紧急主动失效可加入Redis黑名单校验时检查4.5 核心代码实现// 登录publicLoginResultlogin(Stringusername,Stringpassword){UseruseruserService.authenticate(username,password);// 生成AccessToken15分钟过期轻量用于高频API调用StringaccessTokenJwtUtil.generateToken(user,15*60);// 生成RefreshToken7天过期用于静默续期StringrefreshTokenUUID.randomUUID().toString();redisTemplate.opsForValue().set(refresh:token:refreshToken,JSON.toJSONString(user),7,TimeUnit.DAYS);returnnewLoginResult(accessToken,refreshToken);}// 刷新TokenpublicLoginResultrefreshToken(StringrefreshToken){Stringkeyrefresh:token:refreshToken;StringuserJsonredisTemplate.opsForValue().get(key);if(userJsonnull){thrownewBizException(RefreshToken已过期请重新登录);}UseruserJSON.parseObject(userJson,User.class);// 删除旧的RefreshToken防止Token重放攻击redisTemplate.delete(key);// 生成新的AccessToken和RefreshTokenStringnewAccessTokenJwtUtil.generateToken(user,15*60);StringnewRefreshTokenUUID.randomUUID().toString();redisTemplate.opsForValue().set(refresh:token:newRefreshToken,JSON.toJSONString(user),7,TimeUnit.DAYS);returnnewLoginResult(newAccessToken,newRefreshToken);}// 退出登录主动失效publicvoidlogout(StringrefreshToken){redisTemplate.delete(refresh:token:refreshToken);}五、全局异常处理与标准响应体疑问权限校验失败、Token过期这些异常怎么统一处理回答用RestControllerAdvice统一捕获异常返回标准格式的响应体。5.1 标准响应体DatapublicclassResultT{privateIntegercode;// 状态码privateStringmessage;// 提示信息privateTdata;// 返回数据publicstaticTResultTsuccess(Tdata){returnnewResult(200,success,data);}publicstaticTResultTfail(Integercode,Stringmessage){returnnewResult(code,message,null);}}5.2 全局异常处理RestControllerAdvicepublicclassGlobalExceptionHandler{// 权限不足异常ExceptionHandler(AccessDeniedException.class)publicResult?handleAccessDenied(AccessDeniedExceptione){returnResult.fail(403,e.getMessage());}// Token过期异常ExceptionHandler(TokenExpiredException.class)publicResult?handleTokenExpired(TokenExpiredExceptione){returnResult.fail(401,Token已过期请刷新或重新登录);}// 业务异常ExceptionHandler(BizException.class)publicResult?handleBizException(BizExceptione){returnResult.fail(e.getCode(),e.getMessage());}}六、认证-鉴权链的完整架构┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ 客户端 │────→│ JWT过滤器 │────→│ 鉴权AOP │────→│ 业务逻辑 │ └─────────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │ │ │ 解析AccessToken │ 解析RequirePermission │ 提取用户信息 │ 从Redis/DB获取用户权限 │ 存入Security上下文│ 比对注解中的权限码 │ │ │ AccessToken过期 │ │ → 返回401 │ 权限不足 │ → 客户端走刷新流程│ → 返回403七、面试中这样回答权限系统问题面试官“你们系统的权限是怎么设计的”回答框架“我用RBAC模型用户-角色-权限三级关联。接口上加RequirePermission注解声明权限要求Spring AOP拦截校验。认证用JWTRedis双Token——AccessToken存用户信息、15分钟过期、用于高频API调用RefreshToken存Redis、7天过期、用于静默续期。退出登录或冻结用户时直接删除Redis中的RefreshToken即实现主动失效。”总结RBAC三级关系用户→角色→权限。用户不直接关联权限改角色权限即可同步给该角色的所有用户这是间接关联带来的根本维护优势五张核心表用户、角色、权限、用户-角色关联、角色-权限关联。关联表加联合唯一索引防止重复关联Spring AOP 自定义注解RequirePermission声明权限要求AOP切面在方法执行前拦截并从Redis/DB获取用户权限集做比对双TokenAccessToken轻量、无状态、短期有效RefreshToken存在服务端Redis、长期有效、可主动删除使其失效。短期和长期的组合解决了安全和体验的失衡Redis的作用存RefreshToken实现主动失效 可选黑名单实现AccessToken紧急失效全局异常处理统一拦截权限不足和Token过期异常返回标准格式响应体不向客户端暴露内部异常细节