前言
在简历上,我写了"启航学习俱乐部"这个项目------一个从0到1独立开发的后台管理系统。其中权限控制是核心模块,涉及RBAC权限模型、Spring AOP方法级鉴权、JWT+Redis双Token认证。
面试中,权限系统是考察设计能力的经典题目。面试官会追着问:
"RBAC的用户-角色-权限三级关系是怎么设计的?"
"你的自定义注解是怎么实现方法级权限控制的?"
"AccessToken和RefreshToken各存什么?为什么分开?"
"Token过期了怎么办?自动续期是怎么做的?"
本文完整复盘这个权限系统的设计思路和实现细节。
本文核心问题:
- RBAC权限模型是什么?用户-角色-权限三级关系怎么设计?
- 数据库表结构怎么设计?用户表、角色表、权限表、关联表怎么关联?
- Spring AOP + 自定义注解怎么实现方法级动态权限校验?
- JWT单Token有什么安全问题?为什么需要双Token?
- AccessToken和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, 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过期异常,返回标准格式响应体,不向客户端暴露内部异常细节