RBAC权限系统第二阶段_登录鉴权与权限控制详解
RBAC 权限系统第二阶段:登录鉴权、角色权限分配、菜单权限树与接口权限控制
本章位置:第二阶段 Java 核心框架
对应课程:综合案例:RBAC 权限系统(第二阶段)
前置知识:SpringBoot、MyBatis、RESTful、事务、注解、反射、第一阶段 RBAC 数据库设计与 CRUD
学习目标:在第一阶段 RBAC 数据模型之上,完成真正可用的登录认证、Token 身份识别、接口权限校验、用户角色分配、角色权限分配、菜单权限树、401/403 处理以及完整的前后端鉴权流程。
一、本阶段要解决什么问题
第一阶段我们已经完成了:
User
Role
Permission
user_role
role_permission
也已经能够:
新增用户
新增角色
查询权限
给用户分配角色
给角色分配权限
但是这还不算真正的权限系统。
因为真正项目还必须回答:
用户怎么登录?
登录后服务器怎么知道“你是谁”?
每次请求都要重新输入用户名密码吗?
Token 放在哪里?
接口怎么知道当前用户有没有权限?
为什么有权限的人返回 200?
未登录为什么返回 401?
登录了但没权限为什么返回 403?
前端菜单为什么能动态显示?
按钮权限怎么控制?
角色权限修改以后怎么生效?
本阶段就是解决这些问题。
二、认证和授权再复习
必须先分清两个词:
Authentication
认证
Authorization
授权
认证回答:
你是谁?
授权回答:
你能做什么?
三、认证例子
用户提交:
username = admin
password = 123456
后端验证:
用户名存在
密码正确
用户状态正常
验证通过:
你就是 userId=1
这叫:
Authentication
认证
四、授权例子
当前登录用户:
userId=1
想访问:
DELETE /api/users/10
接口要求:
user:delete
后端检查:
当前用户是否拥有 user:delete
如果有:
允许
如果没有:
403
这叫:
Authorization
授权
五、完整登录鉴权流程
最终要建立:
浏览器 / Vue
↓
POST /api/auth/login
↓
用户名 + 密码
↓
AuthService
↓
查询 User
↓
校验密码
↓
查询 Role
↓
查询 Permission
↓
生成 Token
↓
返回前端
↓
前端保存 Token
↓
后续请求
Authorization: Bearer Token
↓
拦截器
↓
解析 Token
↓
得到 userId
↓
读取用户权限
↓
检查 @RequirePermission
↓
允许 / 401 / 403
六、为什么不能每次请求都带用户名密码
如果每个请求都:
username
password
问题:
密码暴露次数更多
客户端处理麻烦
安全风险增加
接口设计混乱
所以登录成功以后:
换成 Token
七、Token 是什么
Token:
登录凭证
可以理解:
用户登录成功后,服务器签发给客户端的一张“临时身份证明”。
以后请求:
不再传密码
而是:
传 Token
八、Token 常放在哪里
最常见:
Authorization: Bearer <token>
例如:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
九、为什么使用 Authorization Header
因为它表达:
认证凭证
比把 Token 放:
?id=xxx&token=xxx
更规范。
URL 还容易出现在:
浏览器历史
日志
代理日志
所以不推荐 Token 放 Query 参数。
十、JWT 是什么
JWT:
JSON Web Token
通常结构:
Header.Payload.Signature
三部分用:
.
连接。
十一、JWT 三部分
Header
算法信息
Payload
业务声明
Signature
签名
十二、JWT Payload 可以放什么
例如:
{
"userId": 1,
"username": "admin",
"exp": 1780000000
}
十三、JWT Payload 不能放什么
不要放:
密码
身份证号
银行卡
完整隐私数据
数据库连接信息
因为 JWT Payload:
通常只是 Base64URL 编码
不是加密
十四、JWT 的核心价值
JWT 主要解决:
Token 内容是否被篡改
Token 是否过期
Token 对应哪个用户
十五、JWT 不是万能方案
真实项目还需要考虑:
主动退出
Token 撤销
权限实时变化
账号禁用
多端登录
刷新 Token
所以学习阶段先掌握:
核心流程
十六、Token 里到底放权限还是只放 userId
两种常见方式:
方式 1
Token 里放 userId + roles + permissions
方式 2
Token 里只放 userId
请求时再查缓存/数据库权限
十七、Token 放全部权限的优点
请求时少查数据库
鉴权快
缺点:
Token 大
权限修改后旧 Token 仍然保留旧权限
实时性差
十八、Token 只放 userId 的优点
Token 小
权限容易实时更新
禁用用户更容易生效
缺点:
每次请求都需要查询权限
所以通常结合:
Redis 权限缓存
十九、本案例推荐方案
学习阶段推荐:
Token
只保存 userId
然后请求时:
根据 userId 查询/缓存权限
这样更容易理解:
RBAC 真正的数据来源
二十、LoginDTO
public class LoginDTO {
@NotBlank(
message = "用户名不能为空"
)
private String username;
@NotBlank(
message = "密码不能为空"
)
private String password;
// Getter / Setter
}
二十一、LoginUser
这个对象表示:
当前已经认证的用户
例如:
public class LoginUser {
private Long userId;
private String username;
private Set<String> roleCodes;
private Set<String> permissionCodes;
// Getter / Setter
}
二十二、为什么角色和权限用 Set
因为角色/权限:
不应该重复
例如:
ADMIN
USER
权限:
user:list
user:add
使用:
Set<String>
天然去重。
二十三、LoginVO
登录成功返回:
public class LoginVO {
private String token;
private Long userId;
private String username;
private Set<String> roles;
private Set<String> permissions;
// Getter / Setter
}
二十四、为什么 LoginVO 可以返回 permissions
前端需要:
菜单显示
按钮显示
路由控制
所以登录后返回权限集合:
很常见
但是:
前端权限只负责体验
真正安全仍靠后端
二十五、密码必须哈希
数据库绝对不要:
password = 123456
应该保存:
哈希值
例如:
$2a$10$...
二十六、为什么不能自己写 MD5 当密码方案
MD5:
速度快
这对密码来说反而:
不安全
攻击者可以快速暴力破解。
现代密码存储更推荐:
BCrypt
Argon2
PBKDF2
二十七、BCrypt 思想
注册:
123456
↓
BCrypt.encode
↓
hash
↓
数据库
登录:
用户输入 123456
↓
BCrypt.matches
↓
和数据库 hash 比较
二十八、密码不是“解密”
BCrypt 是:
单向哈希
正确流程不是:
数据库密码解密
而是:
拿输入密码和 hash 验证
二十九、PasswordEncoder
如果后面引入 Spring Security Crypto,可以:
PasswordEncoder
例如:
BCryptPasswordEncoder
三十、注册时加密
String encoded =
passwordEncoder.encode(
dto.getPassword()
);
user.setPassword(
encoded
);
三十一、登录校验密码
boolean matches =
passwordEncoder.matches(
dto.getPassword(),
user.getPassword()
);
三十二、AuthService 登录完整思路
1. 查询用户
2. 用户不存在 → LOGIN_FAILED
3. 状态禁用 → USER_DISABLED
4. BCrypt 校验密码
5. 查询角色
6. 查询权限
7. 生成 Token
8. 组装 LoginVO
三十三、AuthService 骨架
@Service
public class AuthService {
private final UserMapper
userMapper;
private final RoleMapper
roleMapper;
private final PermissionMapper
permissionMapper;
private final TokenUtil
tokenUtil;
public AuthService(
UserMapper userMapper,
RoleMapper roleMapper,
PermissionMapper permissionMapper,
TokenUtil tokenUtil
) {
this.userMapper =
userMapper;
this.roleMapper =
roleMapper;
this.permissionMapper =
permissionMapper;
this.tokenUtil =
tokenUtil;
}
}
三十四、登录查询用户
User user =
userMapper.findByUsername(
dto.getUsername()
);
三十五、用户名不存在
不要返回:
用户名不存在
更推荐:
用户名或密码错误
三十六、为什么隐藏具体原因
如果接口分别返回:
用户名不存在
密码错误
攻击者可以:
批量测试用户名
判断:
哪些账号真实存在
三十七、状态检查
if (
user.getStatus() == 0
) {
throw new BusinessException(
"USER_DISABLED",
"用户已被禁用"
);
}
三十八、密码检查
if (
!passwordEncoder.matches(
dto.getPassword(),
user.getPassword()
)
) {
throw new BusinessException(
"LOGIN_FAILED",
"用户名或密码错误"
);
}
三十九、查询角色
List<Role> roles =
roleMapper.findByUserId(
user.getId()
);
转:
Set<String> roleCodes =
roles.stream()
.map(
Role::getRoleCode
)
.collect(
Collectors.toSet()
);
四十、查询权限
List<Permission> permissions =
permissionMapper.findByUserId(
user.getId()
);
转换:
Set<String> permissionCodes =
permissions.stream()
.map(
Permission::getPermissionCode
)
.collect(
Collectors.toSet()
);
四十一、生成 Token
String token =
tokenUtil.createToken(
user.getId()
);
四十二、组装 LoginVO
LoginVO vo =
new LoginVO();
vo.setToken(
token
);
vo.setUserId(
user.getId()
);
vo.setUsername(
user.getUsername()
);
vo.setRoles(
roleCodes
);
vo.setPermissions(
permissionCodes
);
return vo;
四十三、AuthController
@RestController
@RequestMapping(
"/api/auth"
)
public class AuthController {
private final AuthService
authService;
public AuthController(
AuthService authService
) {
this.authService =
authService;
}
@PostMapping(
"/login"
)
public Result<LoginVO> login(
@Valid
@RequestBody
LoginDTO dto
) {
return Result.success(
authService.login(
dto
)
);
}
}
四十四、登录请求
POST /api/auth/login
Content-Type: application/json
Body:
{
"username": "admin",
"password": "123456"
}
四十五、登录成功响应
{
"code": "SUCCESS",
"message": "success",
"data": {
"token": "xxxx",
"userId": 1,
"username": "admin",
"roles": [
"ADMIN"
],
"permissions": [
"user:list",
"user:add",
"user:update",
"user:delete"
]
}
}
四十六、TokenUtil 应该负责什么
只负责:
创建 Token
解析 Token
验证 Token
读取 userId
不要把:
查用户
查角色
查权限
全部塞进 TokenUtil。
四十七、TokenUtil 伪代码
@Component
public class TokenUtil {
public String createToken(
Long userId
) {
// 构造 Token
}
public Long parseUserId(
String token
) {
// 验证签名
// 检查过期
// 返回 userId
}
}
四十八、Token 过期时间
例如:
2 小时
过期后:
要求重新登录
四十九、为什么 Token 要过期
如果 Token 永久有效:
一旦泄露
攻击者可以长期使用
所以必须:
设置过期时间
五十、Refresh Token 简单了解
可以设计:
Access Token
短时间
Refresh Token
较长时间
Access Token 到期:
用 Refresh Token 换新 Access Token
当前案例:
了解即可
五十一、后续请求怎么带 Token
例如:
GET /api/users
Authorization: Bearer xxxx
五十二、Vue / Axios 可以统一加 Header
例如:
axios.defaults.headers.common[
"Authorization"
] =
"Bearer "
+ token
实际项目更常:
Axios Request Interceptor
统一添加。
五十三、为什么需要后端拦截器
如果每个 Controller 都写:
String token =
request.getHeader(
"Authorization"
);
if (
token == null
) {
}
会大量重复。
所以统一使用:
Interceptor
五十四、HandlerInterceptor
定义:
@Component
public class AuthInterceptor
implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
)
throws Exception {
return true;
}
}
五十五、preHandle 什么时候执行
在:
Controller 方法执行之前
流程:
HTTP Request
↓
DispatcherServlet
↓
Interceptor.preHandle
↓
Controller
五十六、拦截器第一步:判断 HandlerMethod
if (
!(handler
instanceof HandlerMethod handlerMethod)
) {
return true;
}
五十七、为什么要判断
某些请求:
静态资源
框架内部 Handler
不一定是:
Controller 方法
五十八、读取 Authorization
String authorization =
request.getHeader(
"Authorization"
);
五十九、Token Header 校验
if (
authorization == null
||
!authorization.startsWith(
"Bearer "
)
) {
throw new UnauthorizedException(
"请先登录"
);
}
六十、去掉 Bearer
String token =
authorization.substring(
7
);
因为:
"Bearer "
长度:
7
六十一、解析 userId
Long userId =
tokenUtil.parseUserId(
token
);
六十二、Token 错误应该返回什么
Token 缺失
Token 无效
Token 过期
都属于:
401 Unauthorized
六十三、根据 userId 查询用户
不能只相信:
Token 里 userId 存在
还应该检查:
用户是否仍存在
用户是否被禁用
六十四、用户被删除怎么办
旧 Token 仍可能存在。
所以鉴权时查询用户:
不存在
→ 401
六十五、用户被禁用怎么办
旧 Token 也应该:
立即失效
所以:
status=0
→ 401/403
通常按团队规范处理。
推荐:
认证状态失效
→ 401
六十六、构造 LoginUser
LoginUser loginUser =
authService.loadLoginUser(
userId
);
其中包含:
用户信息
角色码
权限码
六十七、loadLoginUser
public LoginUser loadLoginUser(
Long userId
) {
User user =
userMapper.findById(
userId
);
if (
user == null
) {
throw new UnauthorizedException(
"用户不存在"
);
}
if (
user.getStatus() == 0
) {
throw new UnauthorizedException(
"用户已禁用"
);
}
List<Role> roles =
roleMapper.findByUserId(
userId
);
List<Permission> permissions =
permissionMapper.findByUserId(
userId
);
// 组装并返回
}
六十八、把 LoginUser 放 request
request.setAttribute(
"loginUser",
loginUser
);
这样当前请求后面:
Controller
就可以拿到。
六十九、为什么用 request attribute
LoginUser 是:
当前请求临时数据
生命周期应该:
当前请求
所以 request 很适合。
七十、不要放 Controller 成员变量
错误:
private LoginUser loginUser;
Controller 默认:
单例 Bean
多个用户并发请求:
会互相覆盖
七十一、自定义 @RequirePermission
@Target(
ElementType.METHOD
)
@Retention(
RetentionPolicy.RUNTIME
)
public @interface RequirePermission {
String value();
}
七十二、为什么 Retention 必须 RUNTIME
因为拦截器需要:
运行时
通过反射:
读取注解
七十三、Controller 使用权限注解
@GetMapping
@RequirePermission(
"user:list"
)
public Result<?> list() {
}
七十四、新增接口权限
@PostMapping
@RequirePermission(
"user:add"
)
public Result<?> add() {
}
七十五、修改接口权限
@PutMapping(
"/{id}"
)
@RequirePermission(
"user:update"
)
public Result<?> update() {
}
七十六、删除接口权限
@DeleteMapping(
"/{id}"
)
@RequirePermission(
"user:delete"
)
public Result<?> delete() {
}
七十七、拦截器读取注解
RequirePermission requirePermission =
handlerMethod
.getMethodAnnotation(
RequirePermission.class
);
七十八、没有注解怎么办
如果:
requirePermission == null
说明:
该接口只要求登录
不要求额外权限
可以:
return true;
七十九、需要权限时
String permissionCode =
requirePermission.value();
例如:
user:delete
八十、检查权限
if (
!loginUser
.getPermissionCodes()
.contains(
permissionCode
)
) {
throw new ForbiddenException(
"无权访问"
);
}
八十一、无权限为什么是 403
因为用户已经:
通过 Token 确认身份
但:
没有目标权限
所以:
403 Forbidden
八十二、401 和 403 一定要分清
401
你是谁都没证明
403
知道你是谁,但你没权限
八十三、权限拦截完整流程
请求
↓
读取 Authorization
↓
Token 是否存在
├─ 否 → 401
↓
Token 是否有效
├─ 否 → 401
↓
用户是否正常
├─ 否 → 401
↓
构造 LoginUser
↓
读取 @RequirePermission
↓
是否需要权限
├─ 否 → 放行
↓
permissionCodes 是否包含目标权限
├─ 否 → 403
↓
放行
八十四、注册拦截器
@Configuration
public class WebMvcConfig
implements WebMvcConfigurer {
private final AuthInterceptor
authInterceptor;
public WebMvcConfig(
AuthInterceptor authInterceptor
) {
this.authInterceptor =
authInterceptor;
}
@Override
public void addInterceptors(
InterceptorRegistry registry
) {
registry
.addInterceptor(
authInterceptor
)
.addPathPatterns(
"/api/**"
)
.excludePathPatterns(
"/api/auth/login"
);
}
}
八十五、为什么排除 login
登录接口:
本来就是为了获得 Token
如果先要求:
必须有 Token
逻辑循环。
八十六、还可以排除哪些接口
例如:
验证码
健康检查
公开文章
注册接口
根据业务:
决定是否公开
八十七、不要用 exclude 排除太多敏感接口
否则容易:
忘记保护某些接口
真实项目更推荐:
默认保护
少量明确公开
八十八、全局异常处理 401
@ExceptionHandler(
UnauthorizedException.class
)
public ResponseEntity<Result<Void>>
handleUnauthorized(
UnauthorizedException e
) {
return ResponseEntity
.status(
HttpStatus.UNAUTHORIZED
)
.body(
Result.fail(
"UNAUTHORIZED",
e.getMessage()
)
);
}
八十九、403 处理
@ExceptionHandler(
ForbiddenException.class
)
public ResponseEntity<Result<Void>>
handleForbidden(
ForbiddenException e
) {
return ResponseEntity
.status(
HttpStatus.FORBIDDEN
)
.body(
Result.fail(
"FORBIDDEN",
e.getMessage()
)
);
}
九十、统一错误响应
401:
{
"code": "UNAUTHORIZED",
"message": "请先登录",
"data": null
}
403:
{
"code": "FORBIDDEN",
"message": "无权访问",
"data": null
}
九十一、前端收到 401 怎么办
通常:
清理 Token
↓
跳转登录页
九十二、前端收到 403 怎么办
通常:
提示无权限
不要:
自动跳登录
因为用户其实:
已经登录
只是:
权限不足
九十三、Axios Response Interceptor
前端可统一:
response interceptor
判断:
401
403
避免每个页面重复处理。
九十四、/api/auth/me
非常推荐增加:
GET /api/auth/me
作用:
返回当前登录用户
角色
权限
九十五、为什么需要 /me
页面刷新后:
内存中的用户信息可能丢失
但 Token 还在。
前端可:
带 Token 请求 /me
恢复:
用户
角色
权限
菜单
九十六、me Controller
@GetMapping(
"/me"
)
public Result<LoginUser> me(
HttpServletRequest request
) {
LoginUser loginUser =
(LoginUser)
request.getAttribute(
"loginUser"
);
return Result.success(
loginUser
);
}
九十七、当前接口只要求登录
/me:
不需要 user:list
所以可以:
不加 @RequirePermission
但仍会经过:
AuthInterceptor
验证登录。
九十八、用户分配角色第二阶段完善
接口:
PUT /api/users/{id}/roles
Body:
{
"roleIds": [
1,
2
]
}
九十九、接口需要什么权限
例如:
user:assign-role
Controller:
@PutMapping(
"/{id}/roles"
)
@RequirePermission(
"user:assign-role"
)
public Result<Void> assignRoles(
@PathVariable
Long id,
@Valid
@RequestBody
AssignRoleDTO dto
) {
userService.assignRoles(
id,
dto.getRoleIds()
);
return Result.success(
null
);
}
一百、角色分配前要验证 roleIds
不能直接:
前端传 999999
就插入中间表。
Service 应该:
验证所有角色是否存在
一百零一、批量查询角色
Mapper:
List<Role> findByIds(
@Param("ids")
List<Long> ids
);
XML:
<select
id="findByIds"
resultType="Role">
SELECT
id,
role_code,
role_name,
status
FROM sys_role
WHERE id IN
<foreach
collection="ids"
item="id"
open="("
separator=","
close=")"
>
#{id}
</foreach>
</select>
一百零二、检查数量
List<Role> roles =
roleMapper.findByIds(
roleIds
);
if (
roles.size()
!=
roleIds.stream()
.distinct()
.count()
) {
throw new BusinessException(
"ROLE_NOT_FOUND",
"存在无效角色"
);
}
一百零三、为什么先 distinct
前端可能:
{
"roleIds": [1, 1, 2]
}
应该:
去重
一百零四、分配角色事务
完整:
@Transactional
public void assignRoles(
Long userId,
List<Long> roleIds
) {
User user =
userMapper.findById(
userId
);
if (
user == null
) {
throw new BusinessException(
"USER_NOT_FOUND",
"用户不存在"
);
}
List<Long> distinctRoleIds =
roleIds.stream()
.distinct()
.toList();
validateRoles(
distinctRoleIds
);
userRoleMapper.deleteByUserId(
userId
);
if (
!distinctRoleIds.isEmpty()
) {
userRoleMapper.batchInsert(
userId,
distinctRoleIds
);
}
// 如果有权限缓存
// 删除当前用户权限缓存
}
一百零五、为什么角色分配后要清权限缓存
因为:
角色变了
↓
权限也可能变
旧缓存如果不删:
用户仍然得到旧权限
一百零六、角色分配权限
接口:
PUT /api/roles/{id}/permissions
Body:
{
"permissionIds": [
1,
2,
3
]
}
一百零七、接口权限码
例如:
role:assign-permission
一百零八、Service 流程
查询 Role
↓
校验 role 存在
↓
校验 permissionIds
↓
删除旧关系
↓
批量插入新关系
↓
事务提交
↓
清理受影响用户权限缓存
一百零九、最大的难点:哪些用户缓存要清
角色权限变了:
所有拥有该角色的用户
权限都变了。
所以需要:
查出 roleId 对应 userIds
然后:
批量清缓存
一百一十、UserRoleMapper 增加查询
List<Long> findUserIdsByRoleId(
Long roleId
);
SQL:
SELECT user_id
FROM sys_user_role
WHERE role_id = #{roleId};
一百一十一、为什么 user_role 需要 role_id 索引
中间表主键:
(user_id, role_id)
查询:
WHERE role_id = ?
不符合:
最左前缀
所以建议:
CREATE INDEX idx_user_role_role_id
ON sys_user_role(role_id);
一百一十二、这就是 MySQL 知识真正进入项目
我们不是为了背:
最左前缀
而是项目真的出现:
按 role_id 查询
此时就知道:
为什么要补索引
一百一十三、菜单权限树是什么
后端系统常有:
系统管理
├─ 用户管理
├─ 角色管理
└─ 权限管理
这些菜单:
并不是所有用户都能看
所以菜单也可以作为:
Permission
的一种类型。
一百一十四、permission_type
推荐:
MENU
BUTTON
API
一百一十五、MENU
表示:
页面菜单
例如:
系统管理
用户管理
一百一十六、BUTTON
表示:
页面按钮
例如:
新增用户
删除用户
一百一十七、API
表示:
后端接口权限
例如:
user:delete
一百一十八、菜单树表字段
sys_permission 可以增加/保留:
id
parent_id
permission_code
permission_name
permission_type
path
sort
status
一百一十九、示例菜单数据
1 系统管理 MENU parent=0
2 用户管理 MENU parent=1
3 角色管理 MENU parent=1
4 用户查询 API parent=2
5 用户新增 BUTTON parent=2
6 用户删除 BUTTON parent=2
一百二十、为什么 parent_id 建树
例如:
系统管理
是父节点。
用户管理
角色管理
是:
系统管理的 children
一百二十一、PermissionTreeVO
public class PermissionTreeVO {
private Long id;
private Long parentId;
private String permissionCode;
private String permissionName;
private String permissionType;
private String path;
private List<PermissionTreeVO>
children =
new ArrayList<>();
}
一百二十二、菜单树不要递归查数据库
错误:
查根节点
↓
每个根节点查 children
↓
每个 child 再查 children
会形成:
N+1
一百二十三、正确菜单树思路
一次查询所有权限
↓
Java 内存构建树
一百二十四、第一种建树方式:递归
步骤:
1. 找 parentId=0
2. 对每个节点查 list 中的 children
3. 递归
一百二十五、递归方法
private List<PermissionTreeVO>
buildChildren(
Long parentId,
List<PermissionTreeVO> all
) {
return all.stream()
.filter(
item ->
Objects.equals(
item.getParentId(),
parentId
)
)
.peek(
item ->
item.setChildren(
buildChildren(
item.getId(),
all
)
)
)
.toList();
}
一百二十六、递归方法的问题
如果权限很多:
每个节点都遍历 all
时间复杂度可能较高。
更高效可以:
先按 parentId 分组
一百二十七、第二种建树:groupingBy
Map<Long, List<PermissionTreeVO>>
childrenMap =
all.stream()
.collect(
Collectors.groupingBy(
PermissionTreeVO::getParentId
)
);
然后:
parentId
直接查 childrenMap
效率更好。
一百二十八、权限树接口
GET /api/permissions/tree
一百二十九、权限树需要什么权限
例如:
permission:list
Controller:
@GetMapping(
"/tree"
)
@RequirePermission(
"permission:list"
)
public Result<List<PermissionTreeVO>>
tree() {
return Result.success(
permissionService.getTree()
);
}
一百三十、角色已拥有权限接口
GET /api/roles/{id}/permissions
返回:
permissionIds
或者:
完整 PermissionVO
前端角色编辑页面可以:
勾选当前已有权限
一百三十一、角色分配权限页面流程
打开角色编辑页面
↓
GET /permissions/tree
↓
获取全部权限树
↓
GET /roles/{id}/permissions
↓
获取已拥有权限
↓
前端勾选
↓
用户修改
↓
PUT /roles/{id}/permissions
↓
保存
一百三十二、用户分配角色页面流程
GET /roles
↓
GET /users/{id}/roles
↓
前端勾选
↓
PUT /users/{id}/roles
一百三十三、前端菜单生成流程
登录成功:
拿到 permissions
或者:
GET /auth/me
后端返回:
当前用户菜单树
前端:
根据菜单树生成侧边栏
一百三十四、推荐增加当前用户菜单接口
GET /api/auth/menus
返回:
当前用户拥有的 MENU 类型权限树
一百三十五、查询当前用户菜单 SQL
核心思路:
SELECT DISTINCT
p.id,
p.parent_id,
p.permission_code,
p.permission_name,
p.permission_type,
p.path
FROM sys_user_role ur
INNER JOIN sys_role_permission rp
ON ur.role_id = rp.role_id
INNER JOIN sys_permission p
ON rp.permission_id = p.id
WHERE
ur.user_id = #{userId}
AND p.permission_type = 'MENU';
一百三十六、为什么 DISTINCT
一个用户可能:
多个角色
多个角色可能同时拥有:
同一个权限
所以 JOIN 结果可能重复。
用:
DISTINCT
去重。
一百三十七、也可以 Java Set 去重
SQL:
不 DISTINCT
Java:
按照 permission id/code 去重
两种都可以。
一百三十八、按钮权限怎么做
例如 Vue:
用户有 user:add
显示新增按钮
前端判断:
permissions.includes(
"user:add"
)
一百三十九、按钮隐藏不是后端鉴权替代品
即使:
按钮没显示
用户也可以:
自己构造 POST /api/users
所以后端:
@RequirePermission("user:add")
仍然必须。
一百四十、接口权限建议和按钮权限是否用同一个 code
学习阶段可以:
user:add
同时表示:
新增按钮显示
+
新增接口权限
简单直观。
一百四十一、更复杂系统可能分开
例如:
user:add:button
user:add:api
但会增加:
维护成本
不要过度设计。
一百四十二、超级管理员设计
最简单:
ADMIN 角色
绑定全部 Permission
这样所有权限:
都来自数据库
一百四十三、为什么不推荐 userId=1 永远放行
例如:
if (
userId == 1
) {
return true;
}
问题:
逻辑写死
迁移困难
审计困难
权限模型不统一
一百四十四、是否可以设置超级权限码
例如:
*:*:*
如果角色拥有:
*:*:*
代表:
全部权限
这是另一种可行方案。
一百四十五、权限检查支持超级权限
if (
loginUser
.getPermissionCodes()
.contains(
"*:*:*"
)
) {
return true;
}
一百四十六、多个权限怎么处理
有些接口可能要求:
满足任一权限
或者:
同时满足多个权限
可以扩展注解:
String[] value();
Logical logical()
default Logical.AND;
一百四十七、Logical
public enum Logical {
AND,
OR
}
一百四十八、注解示例
@RequirePermissions(
value = {
"user:update",
"user:admin"
},
logical = Logical.OR
)
表示:
有任意一个即可
一百四十九、当前阶段先不要过度扩展
先把:
一个接口
一个权限码
做正确。
再理解:
AND / OR
扩展。
一百五十、角色本身也可以用于鉴权吗
可以。
例如:
某个接口只允许 ADMIN
但 RBAC 中更推荐:
权限码鉴权
因为角色是:
权限集合
一百五十一、为什么权限码更灵活
例如新增一个角色:
AUDITOR
只需要配置:
order:list
order:audit
Controller 无需:
修改代码判断角色名
一百五十二、权限码命名规范
推荐:
模块:动作
例如:
user:list
user:add
user:update
user:delete
role:list
role:add
role:update
role:delete
role:assign-permission
permission:list
一百五十三、权限码为什么不要用中文
不推荐:
用户删除
作为内部 code。
推荐:
user:delete
中文放:
permission_name
一百五十四、接口权限与 path 是否要一一存数据库
有些系统:
permission
只存 permissionCode
代码:
@RequirePermission
决定权限。
一百五十五、另一种系统
数据库保存:
path
method
例如:
/api/users
GET
拦截器根据 URL + Method:
自动匹配权限
一百五十六、两种方案对比
注解方式:
优点
直观
代码旁可见
缺点
需要开发者加注解
数据库 URL 方式:
优点
动态
缺点
路径匹配复杂
维护成本高
一百五十七、本案例推荐
学习阶段:
自定义注解
+
permissionCode
更容易理解:
注解
反射
拦截器
RBAC
一百五十八、角色权限分配事务再强调
操作:
delete old relations
↓
insert new relations
必须:
同一个事务
一百五十九、事务异常不能吞
错误:
@Transactional
public void assignPermissions() {
try {
...
} catch (
Exception e
) {
System.out.println(
e.getMessage()
);
}
}
一百六十、为什么会造成事务问题
如果异常被 catch 后:
方法正常返回
Spring 可能认为:
事务成功
于是:
commit
一百六十一、正确做法
catch (
Exception e
) {
throw e;
}
或者:
直接不 catch
让异常抛出
一百六十二、@Transactional 自调用问题
例如:
public void updateRole() {
this.assignPermissions();
}
@Transactional
public void assignPermissions() {
}
某些 Spring AOP 代理场景:
事务不会生效
一百六十三、为什么
因为:
this.assignPermissions()
没有经过:
Spring Proxy
而事务增强:
通常在 Proxy 上
一百六十四、正确理解
@Transactional 不是:
Java 关键字
它的生效依赖:
Spring AOP / 代理机制
一百六十五、这个案例和下一章 AOP 的连接
权限:
横切逻辑
事务:
横切逻辑
日志:
横切逻辑
它们都不属于:
某个单一业务
所以 AOP 很适合解决。
一百六十六、权限缓存设计预览
如果每个请求都:
user_role
↓
role_permission
↓
permission
数据库压力会增加。
可以缓存:
rbac:user:permissions:{userId}
一百六十七、缓存值
例如:
[
"user:list",
"user:add",
"user:update"
]
一百六十八、什么时候删权限缓存
至少:
用户角色变化
角色权限变化
角色禁用
权限禁用
用户禁用
一百六十九、为什么缓存失效比缓存读取更难
读取:
没有 → 查数据库 → 放缓存
很简单。
困难是:
哪些业务操作会影响权限?
只要漏一个:
就会出现权限不同步
一百七十、角色权限修改影响多个用户
例如:
ADMIN 角色
有 1000 用户
管理员修改:
ADMIN 权限
影响:
1000 用户
这就是:
批量缓存失效问题
一百七十一、学习阶段可以不加 Redis
先做到:
每次查数据库
功能正确。
后面 Redis 阶段:
再加缓存
这样学习顺序最清楚。
一百七十二、登录日志建议
真实项目可以记录:
登录用户
登录时间
IP
User-Agent
成功/失败
但不要记录:
明文密码
Token 完整值
一百七十三、为什么 Token 也不要完整打印日志
日志平台:
可能很多人可访问
Token 一旦泄露:
可能直接伪装用户
一百七十四、接口审计日志预览
例如:
谁
在什么时候
调用什么接口
修改了什么资源
这类日志后面可以通过:
AOP
统一实现。
一百七十五、Postman 完整联调 1:登录
POST /api/auth/login
Body:
{
"username": "admin",
"password": "123456"
}
复制:
token
一百七十六、Postman 完整联调 2:添加 Header
Authorization:
Bearer Token
一百七十七、测试 /me
GET /api/auth/me
应该返回:
当前用户
角色
权限
一百七十八、测试 user:list
管理员:
GET /api/users
应该:
200
一百七十九、测试普通用户
普通用户如果只有:
user:list
调用:
GET /api/users
成功。
一百八十、普通用户删除
调用:
DELETE /api/users/2
因为没有:
user:delete
应该返回:
403
一百八十一、未登录测试
不带 Token:
GET /api/users
应该:
401
一百八十二、错误 Token
Authorization: Bearer abc
应该:
401
一百八十三、过期 Token
模拟 Token 过期。
应该:
401
一百八十四、禁用用户测试
用户已登录。
数据库:
UPDATE sys_user
SET status = 0
WHERE id = 2;
再次请求。
如果鉴权每次检查用户状态:
应该失效
一百八十五、用户分配角色测试
PUT /api/users/2/roles
Body:
{
"roleIds": [1]
}
然后重新获取:
/me
观察权限是否变化。
一百八十六、角色分配权限测试
PUT /api/roles/2/permissions
Body:
{
"permissionIds": [
1,
2
]
}
一百八十七、测试角色权限事务
故意让:
batchInsert
发生异常。
确认:
旧 role_permission
是否恢复
一百八十八、测试用户角色事务
同样:
delete old
↓
batch insert 报错
确认:
旧 user_role 仍然存在
一百八十九、权限树测试
GET /api/permissions/tree
验证:
父子结构
children
排序
一百九十、当前用户菜单测试
GET /api/auth/menus
管理员:
菜单更多
普通用户:
菜单更少
一百九十一、菜单与接口权限不要混淆
菜单:
决定前端展示
接口权限:
决定后端能否执行
二者:
可以对应
但职责不同
一百九十二、前端有菜单不等于有全部按钮
例如:
用户管理菜单
用户可能只有:
user:list
所以:
能看列表
不能新增
不能删除
一百九十三、菜单权限树典型结构
系统管理
│
├─ 用户管理
│ ├─ user:list
│ ├─ user:add
│ ├─ user:update
│ └─ user:delete
│
└─ 角色管理
├─ role:list
├─ role:add
├─ role:update
└─ role:assign-permission
一百九十四、不要一上来做复杂前端动态路由
学习阶段先:
后端返回 menus
↓
前端根据 menus 显示菜单
理解清楚后:
再做动态 router.addRoute
一百九十五、RBAC 第二阶段推荐开发顺序
1. BCrypt 密码
2. LoginDTO / LoginVO
3. AuthService.login
4. TokenUtil
5. AuthController.login
6. Postman 登录跑通
7. LoginUser
8. AuthInterceptor
9. WebMvcConfig
10. /auth/me
11. RequirePermission
12. 401/403 异常
13. 用户角色分配完善
14. 角色权限分配完善
15. Permission Tree
16. 当前用户菜单
17. 完整权限测试
一百九十六、为什么必须按这个顺序
如果先做:
权限注解
但:
当前用户都获取不到
那无法测试。
所以先:
登录
↓
身份
↓
权限
顺序最稳。
一百九十七、IDEA 快速创建注解
在:
security
包:
Alt + Insert
→ Java Class
→ Annotation
如果 IDEA 没直接提供 Annotation 类型:
public @interface RequirePermission {
}
手写即可。
一百九十八、IDEA 快速实现 HandlerInterceptor
创建:
AuthInterceptor
然后:
implements HandlerInterceptor
按:
Ctrl + I
选择:
preHandle
一百九十九、IDEA 生成构造器
写完 final 字段:
Alt + Insert
→ Constructor
快速生成构造器注入。
二百、快速修复 import
如果:
类名变红
按:
Alt + Enter
选择:
Import class
二百零一、常见错误:登录接口 404
检查:
@RestController
@RequestMapping("/api/auth")
@PostMapping("/login")
启动类扫描范围
端口
二百零二、常见错误:登录 400
检查:
JSON 格式
Content-Type
字段名
@Valid
@NotBlank
二百零三、常见错误:密码永远匹配失败
可能:
注册保存的是明文
登录使用 BCrypt.matches
或者:
每次登录又把输入密码重新 encode 后直接 equals
二百零四、为什么 BCrypt 不能 encode 后 equals
BCrypt 每次 encode:
可能产生不同 hash
所以必须:
matches(
rawPassword,
encodedPassword
)
二百零五、常见错误:Token 解析失败
检查:
签名密钥
算法
过期时间
Bearer 前缀
substring
二百零六、常见错误:所有接口都 401
检查:
登录接口是否被排除
前端是否真的发送 Authorization
Bearer 后是否有空格
Token 是否过期
二百零七、常见错误:所有接口都 403
检查:
permissionCodes 是否真的查出来
permissionCode 拼写
@RequirePermission 值
角色权限中间表
二百零八、常见错误:权限码不一致
数据库:
user:delete
代码:
@RequirePermission(
"user:remove"
)
永远:
403
二百零九、常见错误:权限查询重复
用户多个角色:
可能拥有相同权限
建议:
SQL DISTINCT
或
Java Set
二百一十、常见错误:Permission Tree 死循环
如果数据库出现:
parent_id 指向自己
或者:
A parent=B
B parent=A
递归可能:
无限循环
二百一十一、权限树数据要保证合法
新增/修改权限时:
禁止 parentId = 自己 id
更复杂还要:
避免形成环
二百一十二、常见错误:删除父菜单后子权限变孤儿
删除父 Permission:
需要处理 children
可以:
禁止删除有子节点的权限
或:
级联处理
学习项目更推荐:
有子节点则禁止删除
二百一十三、常见错误:删除角色后权限中间表残留
需要事务:
delete role_permission
delete user_role
delete role
二百一十四、常见错误:删除用户后 user_role 残留
需要:
delete user_role
delete user
一个事务。
二百一十五、常见错误:权限修改后用户仍有旧权限
如果没有缓存:
检查 Token 是否内嵌 permissions
如果 Token 里保存权限:
必须重新登录
二百一十六、为什么本案例推荐 Token 只放 userId
就是为了避免:
权限长期固化在 Token
二百一十七、常见错误:拦截器抛异常但全局异常处理没捕获
需要注意:
Interceptor 异常
是否进入 ControllerAdvice,取决于异常传播和 MVC 流程。
如果实际项目发现:
未统一响应
可以考虑:
在拦截器中直接写 Response
或者:
使用 Filter + 统一异常策略
学习阶段先理解差异。
二百一十八、Interceptor 和 Filter 区别预览
Filter:
Servlet 规范层
更靠前
Interceptor:
Spring MVC 层
可以拿 HandlerMethod
二百一十九、为什么权限注解更适合 Interceptor
因为:
Interceptor 能直接拿到 HandlerMethod
所以容易:
读取 Controller 方法注解
二百二十、Spring Security 为什么更多使用 Filter Chain
因为认证授权属于:
Web 请求安全基础设施
Spring Security 建立了完整:
Filter Chain
以后再系统学习。
二百二十一、RBAC 第二阶段完整数据流
登录:
LoginDTO
↓
AuthController
↓
AuthService
↓
UserMapper
↓
PasswordEncoder
↓
RoleMapper
↓
PermissionMapper
↓
TokenUtil
↓
LoginVO
二百二十二、普通请求数据流
HTTP Request
↓
Authorization Header
↓
AuthInterceptor
↓
TokenUtil
↓
userId
↓
LoginUser
↓
RequirePermission
↓
permissionCodes.contains
↓
Controller
↓
Service
↓
Mapper
二百二十三、角色权限变更流
PUT /roles/{id}/permissions
↓
RoleController
↓
RoleService
↓
校验 Role
↓
校验 Permission
↓
delete old relation
↓
batch insert
↓
commit
↓
清权限缓存
二百二十四、用户角色变更流
PUT /users/{id}/roles
↓
UserController
↓
UserService
↓
校验 User
↓
校验 Role
↓
delete old relation
↓
batch insert
↓
commit
↓
清用户权限缓存
二百二十五、必须掌握的认证知识
LoginDTO
Password Hash
BCrypt
Token
Bearer
JWT
过期时间
LoginUser
401
二百二十六、必须掌握的授权知识
Role
Permission
permissionCode
RequirePermission
Interceptor
403
二百二十七、必须掌握的权限树
parentId
MENU
BUTTON
API
children
递归
groupingBy
避免 N+1
二百二十八、必须掌握的事务知识
用户分配角色
角色分配权限
删除角色
删除用户
都可能涉及:
多表操作
必须考虑:
事务
二百二十九、必须回答的问题
学完后应该能够回答:
1. Authentication 和 Authorization 有什么区别?
2. 为什么登录属于认证?
3. 为什么权限检查属于授权?
4. Token 是什么?
5. 为什么后续请求不应该重复提交密码?
6. Authorization Bearer Token 是什么?
7. JWT 是加密吗?
8. JWT Payload 能不能放密码?
9. Token 只放 userId 有什么优缺点?
10. 为什么密码不能明文保存?
11. BCrypt 为什么要用 matches 而不是 equals?
12. 401 和 403 有什么区别?
13. LoginUser 是什么?
14. 为什么 LoginUser 不应该放 Controller 成员变量?
15. HandlerInterceptor 在什么时候执行?
16. 为什么权限注解要 RetentionPolicy.RUNTIME?
17. HandlerMethod 有什么作用?
18. @RequirePermission 怎么实现权限控制?
19. 为什么前端隐藏按钮不能替代后端鉴权?
20. 用户分配角色为什么需要事务?
21. 角色分配权限为什么需要事务?
22. 角色权限变化为什么会影响多个用户?
23. 权限缓存什么时候需要失效?
24. 什么是 MENU、BUTTON、API 权限?
25. 为什么权限树不推荐递归查数据库?
26. parent_id 在菜单树中有什么作用?
27. 为什么 user_role 可能需要 role_id 索引?
28. Token 内嵌全部权限为什么实时性差?
29. 为什么推荐 permissionCode 而不是写死 ADMIN?
30. Interceptor 和 Filter 有什么区别?
二百三十、本阶段知识结构
RBAC 第二阶段
│
├─ Authentication
│ ├─ LoginDTO
│ ├─ BCrypt
│ ├─ Token
│ ├─ JWT
│ ├─ Bearer
│ └─ LoginUser
│
├─ Authorization
│ ├─ permissionCode
│ ├─ RequirePermission
│ ├─ HandlerMethod
│ ├─ Interceptor
│ ├─ 401
│ └─ 403
│
├─ Role Assignment
│ ├─ user_role
│ ├─ foreach
│ ├─ transaction
│ └─ cache invalidation
│
├─ Permission Assignment
│ ├─ role_permission
│ ├─ validation
│ ├─ transaction
│ └─ affected users
│
├─ Permission Tree
│ ├─ parent_id
│ ├─ MENU
│ ├─ BUTTON
│ ├─ API
│ ├─ recursion
│ └─ groupingBy
│
└─ Security
├─ Password Hash
├─ Token Expiration
├─ Sensitive Data
├─ Backend Check
└─ Audit
二百三十一、从第二阶段到第三阶段
到这里,RBAC 已经不只是:
数据库关系
而是真正具备:
登录
身份识别
角色
权限
接口保护
菜单树
下一阶段可以继续完善:
完整 RBAC API
统一参数校验
统一异常体系
JWT 完整封装
权限缓存设计
操作日志
AOP 审计
登录日志
事务边界优化
Spring Security 迁移思路
二百三十二、本章总结
本阶段最核心的完整链路:
用户名密码
↓
认证
↓
Token
↓
Bearer Header
↓
AuthInterceptor
↓
解析 userId
↓
LoginUser
↓
读取 @RequirePermission
↓
检查 permissionCode
↓
允许 / 401 / 403
一定分清:
401
未认证
403
已认证但无权限
权限来源:
User
↓
user_role
↓
Role
↓
role_permission
↓
Permission
角色分配:
删除旧关系
+
插入新关系
必须:
@Transactional
角色权限分配同样:
必须事务
菜单权限树:
一次查全部权限
↓
Java 内存组装树
不要:
每个节点递归查数据库
前端:
菜单和按钮权限
负责用户体验
后端:
@RequirePermission
负责真正安全
密码:
必须使用哈希
不能明文存储
不能返回前端
不能写入日志
Token:
必须有过期时间
不能放敏感信息
这一阶段完成后,你已经真正掌握:
RBAC
+
Authentication
+
Authorization
+
SpringBoot Interceptor
+
自定义注解
+
反射
+
事务
+
MyBatis 多表关系
下一篇继续整理:
RBAC 权限系统第三阶段:
统一异常、参数校验、JWT 完整封装、权限缓存、操作日志与 AOP