权限系统设计复盘——从RBAC模型到JWT双Token,方法级权限控制的完整实现

前言

在简历上,我写了"启航学习俱乐部"这个项目------一个从0到1独立开发的后台管理系统。其中权限控制是核心模块,涉及RBAC权限模型、Spring AOP方法级鉴权、JWT+Redis双Token认证。

面试中,权限系统是考察设计能力的经典题目。面试官会追着问:

"RBAC的用户-角色-权限三级关系是怎么设计的?"

"你的自定义注解是怎么实现方法级权限控制的?"

"AccessToken和RefreshToken各存什么?为什么分开?"

"Token过期了怎么办?自动续期是怎么做的?"

本文完整复盘这个权限系统的设计思路和实现细节。

本文核心问题:

  1. RBAC权限模型是什么?用户-角色-权限三级关系怎么设计?
  2. 数据库表结构怎么设计?用户表、角色表、权限表、关联表怎么关联?
  3. Spring AOP + 自定义注解怎么实现方法级动态权限校验?
  4. JWT单Token有什么安全问题?为什么需要双Token?
  5. AccessToken和RefreshToken各自存什么?过期时间怎么定?
  6. Redis在这里做了什么?黑名单和自动续期怎么实现?
  7. 整个认证-鉴权-刷新的完整流程是怎样的?

读完本文,你将对权限系统的设计拥有从数据库到接口的完整理解。


一、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, status
sys_role 角色表 role_id, role_name, role_code
sys_permission 权限表 perm_id, perm_name, perm_code(如user:delete
sys_user_role 用户-角色关联 user_id, role_id
sys_role_permission 角色-权限关联 role_id, perm_id

1.3 权限编码规范

我在项目中使用模块:操作的命名方式定义权限码:

权限编码 含义
user:list 查看用户列表
user:add 新增用户
user:update 修改用户
user:delete 删除用户
course:list 查看课程列表
course:manage 管理课程(上架/下架等)
asset:list 查看资产列表
asset:export 导出资产报表

设计原则:权限码是权限控制的唯一标识,和前端路由、后端接口一一对应。新增功能只需增加对应的权限码,不需要改代码逻辑。


二、数据库表结构设计

sql 复制代码
-- 用户表
CREATE TABLE sys_user (
    user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,        -- BCrypt加密
    email VARCHAR(100),
    status TINYINT DEFAULT 1,              -- 1:启用 0:禁用
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE sys_role (
    role_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_name VARCHAR(50) NOT NULL,
    role_code VARCHAR(50) NOT NULL UNIQUE, -- 如 ADMIN, TEACHER, STUDENT
    description VARCHAR(200),
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 权限表
CREATE TABLE sys_permission (
    perm_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    perm_name VARCHAR(50) NOT NULL,
    perm_code VARCHAR(100) NOT NULL UNIQUE, -- 如 user:delete
    parent_id BIGINT DEFAULT 0,             -- 父权限ID(用于菜单树)
    perm_type VARCHAR(20),                  -- MENU/BUTTON/API
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 用户-角色关联表
CREATE TABLE sys_user_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    UNIQUE KEY uk_user_role (user_id, role_id)
);

-- 角色-权限关联表
CREATE TABLE sys_role_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_id BIGINT NOT NULL,
    perm_id BIGINT NOT NULL,
    UNIQUE KEY uk_role_perm (role_id, perm_id)
);

设计要点

  • 关联表使用联合唯一索引,防止重复关联
  • 权限表设parent_id支持树形结构,可扩展为菜单树
  • 用户密码使用BCrypt加密存储,不保存明文

三、Spring AOP + 自定义注解------方法级权限控制

疑问:怎么在接口上声明"这个接口需要什么权限"?如何校验当前用户是否有这个权限?

回答:用自定义注解声明权限要求,用Spring AOP拦截方法调用并校验。

3.1 自定义注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
    String value();  // 权限码,如 "user:delete"
}

3.2 在Controller上使用

java 复制代码
@RestController
@RequestMapping("/api/user")
public class UserController {
    
    @GetMapping("/list")
    @RequirePermission("user:list")  // 需要 user:list 权限
    public Result listUsers() { ... }
    
    @PostMapping("/add")
    @RequirePermission("user:add")
    public Result addUser(@RequestBody UserReq req) { ... }
    
    @DeleteMapping("/{id}")
    @RequirePermission("user:delete")  // 只有拥有此权限的用户才能调用
    public Result deleteUser(@PathVariable Long id) { ... }
}

3.3 AOP切面实现权限校验

java 复制代码
@Aspect
@Component
public class PermissionAspect {
    
    @Autowired
    private PermissionService permissionService;
    
    @Around("@annotation(requirePermission)")
    public Object checkPermission(ProceedingJoinPoint joinPoint, 
                                  RequirePermission requirePermission) throws Throwable {
        // 1. 从Spring Security上下文获取当前用户
        User currentUser = SecurityContextHolder.getContext().getUser();
        
        // 2. 获取当前用户的所有权限码
        Set<String> userPermissions = permissionService
            .getPermissionCodesByUserId(currentUser.getUserId());
        
        // 3. 检查是否拥有所需权限
        String requiredPerm = requirePermission.value();
        if (!userPermissions.contains(requiredPerm)) {
            throw new AccessDeniedException("权限不足: " + requiredPerm);
        }
        
        // 4. 权限校验通过,执行原方法
        return joinPoint.proceed();
    }
}

3.4 权限查询的SQL

sql 复制代码
-- 查询某个用户的所有权限码
SELECT DISTINCT p.perm_code
FROM sys_permission p
JOIN sys_role_permission rp ON p.perm_id = rp.perm_id
JOIN sys_user_role ur ON rp.role_id = ur.role_id
WHERE ur.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 否(无状态,签发后即独立存在)
RefreshToken Redis 7天 获取新的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 核心代码实现

java 复制代码
// 登录
public LoginResult login(String username, String password) {
    User user = userService.authenticate(username, password);
    
    // 生成AccessToken(15分钟过期,轻量,用于高频API调用)
    String accessToken = JwtUtil.generateToken(user, 15 * 60);
    
    // 生成RefreshToken(7天过期,用于静默续期)
    String refreshToken = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set(
        "refresh:token:" + refreshToken, 
        JSON.toJSONString(user),
        7, TimeUnit.DAYS
    );
    
    return new LoginResult(accessToken, refreshToken);
}

// 刷新Token
public LoginResult refreshToken(String refreshToken) {
    String key = "refresh:token:" + refreshToken;
    String userJson = redisTemplate.opsForValue().get(key);
    
    if (userJson == null) {
        throw new BizException("RefreshToken已过期,请重新登录");
    }
    
    User user = JSON.parseObject(userJson, User.class);
    
    // 删除旧的RefreshToken(防止Token重放攻击)
    redisTemplate.delete(key);
    
    // 生成新的AccessToken和RefreshToken
    String newAccessToken = JwtUtil.generateToken(user, 15 * 60);
    String newRefreshToken = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set(
        "refresh:token:" + newRefreshToken,
        JSON.toJSONString(user),
        7, TimeUnit.DAYS
    );
    
    return new LoginResult(newAccessToken, newRefreshToken);
}

// 退出登录(主动失效)
public void logout(String refreshToken) {
    redisTemplate.delete("refresh:token:" + refreshToken);
}

五、全局异常处理与标准响应体

疑问:权限校验失败、Token过期这些异常怎么统一处理?

回答:用@RestControllerAdvice统一捕获异常,返回标准格式的响应体。

5.1 标准响应体

java 复制代码
@Data
public class Result<T> {
    private Integer code;    // 状态码
    private String message;  // 提示信息
    private T data;          // 返回数据
    
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "success", data);
    }
    
    public static <T> Result<T> fail(Integer code, String message) {
        return new Result<>(code, message, null);
    }
}

5.2 全局异常处理

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    // 权限不足异常
    @ExceptionHandler(AccessDeniedException.class)
    public Result<?> handleAccessDenied(AccessDeniedException e) {
        return Result.fail(403, e.getMessage());
    }
    
    // Token过期异常
    @ExceptionHandler(TokenExpiredException.class)
    public Result<?> handleTokenExpired(TokenExpiredException e) {
        return Result.fail(401, "Token已过期,请刷新或重新登录");
    }
    
    // 业务异常
    @ExceptionHandler(BizException.class)
    public Result<?> handleBizException(BizException e) {
        return Result.fail(e.getCode(), e.getMessage());
    }
}

六、认证-鉴权链的完整架构

复制代码
┌─────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│  客户端   │────→│ JWT过滤器 │────→│ 鉴权AOP  │────→│ 业务逻辑  │
└─────────┘     └────┬─────┘     └────┬─────┘     └──────────┘
                     │               │
                     │ 解析AccessToken │ 解析@RequirePermission
                     │ 提取用户信息     │ 从Redis/DB获取用户权限
                     │ 存入Security上下文│ 比对注解中的权限码
                     │               │
                     │ AccessToken过期  │
                     │ → 返回401       │ 权限不足
                     │ → 客户端走刷新流程│ → 返回403

七、面试中这样回答权限系统问题

面试官:"你们系统的权限是怎么设计的?"

回答框架

"我用RBAC模型,用户-角色-权限三级关联。接口上加@RequirePermission注解声明权限要求,Spring AOP拦截校验。认证用JWT+Redis双Token------AccessToken存用户信息、15分钟过期、用于高频API调用;RefreshToken存Redis、7天过期、用于静默续期。退出登录或冻结用户时直接删除Redis中的RefreshToken即实现主动失效。"


总结

  • RBAC三级关系:用户→角色→权限。用户不直接关联权限,改角色权限即可同步给该角色的所有用户,这是"间接关联"带来的根本维护优势
  • 五张核心表:用户、角色、权限、用户-角色关联、角色-权限关联。关联表加联合唯一索引防止重复关联
  • Spring AOP + 自定义注解@RequirePermission声明权限要求,AOP切面在方法执行前拦截并从Redis/DB获取用户权限集做比对
  • 双Token:AccessToken轻量、无状态、短期有效;RefreshToken存在服务端Redis、长期有效、可主动删除使其失效。短期和长期的组合解决了"安全"和"体验"的失衡
  • Redis的作用:存RefreshToken实现主动失效 + 可选黑名单实现AccessToken紧急失效
  • 全局异常处理:统一拦截权限不足和Token过期异常,返回标准格式响应体,不向客户端暴露内部异常细节
相关推荐
凤山老林2 天前
从0到1搭建企业级权限管理系统:Spring Boot + JWT + RBAC实战指南
java·spring boot·后端·权限管理·rbac
深念Y7 天前
Codex 买号登录问题排查与 auth.json 手动配置记录
gpt·jwt·coding·codex·email·refresh_token·access_token
消失的旧时光-19438 天前
Spring Boot 工程化进阶:统一返回 + 全局异常 + AOP 通用工具包
java·spring boot·后端·aop·自定义注解
Devin~Y10 天前
大厂Java面试实录:Spring Boot/Cloud + Redis/Kafka + JWT + RAG/Agent(小Y翻车版)
java·spring boot·redis·spring cloud·kafka·spring security·jwt
Devin~Y17 天前
大厂 Java 面试实战:Spring Boot 微服务 + Redis 缓存 + Kafka 消息 + Kubernetes + RAG(小Y水货翻车记)
java·spring boot·redis·kafka·spring security·jwt·oauth2
蓝色的杯子20 天前
JWT 到底怎么用?一篇讲透 + FastAPI 鉴权实战
python·fastapi·jwt
庞轩px22 天前
反射与动态代理——Java语言动态性的核心
java·spring·反射·aop·动态代理·类型
我登哥MVP25 天前
【Spring6笔记】 - 12 - 代理模式
java·spring boot·笔记·spring·代理模式·aop
我登哥MVP25 天前
【Spring6笔记】 - 13 - 面向切面编程(AOP)
java·开发语言·spring boot·笔记·spring·aop