博客 RBAC 权限模型与安全认证详解
本文详细介绍个人博客系统中基于 RBAC(基于角色的访问控制)模型的权限管理机制,以及使用 Sa-Token 框架实现的安全认证流程。从数据库设计到前后端实现,让小白也能看懂。
📚 目录
- [一、什么是 RBAC?为什么需要它?](#一、什么是 RBAC?为什么需要它?)
- [二、RBAC 模型的核心概念](#二、RBAC 模型的核心概念)
- 三、数据库设计:五张表的关系
- [四、Sa-Token 认证框架简介](#四、Sa-Token 认证框架简介)
- 五、登录认证流程详解
- 六、权限校验流程详解
- 七、后端权限控制实现
- 八、前端权限控制实现
- 九、安全措施与最佳实践
- 十、常见问题与解决方案
一、什么是 RBAC?为什么需要它?
1.1 RBAC 简介
RBAC (Role-Based Access Control,基于角色的访问控制)是一种权限管理模型,它的核心思想是:用户不直接拥有权限,而是通过角色来间接获得权限。
1.2 为什么需要 RBAC?
想象一下,如果没有 RBAC,系统会是什么样子:
❌ 传统方式(用户直接分配权限)
用户A → 权限1、权限2、权限3
用户B → 权限2、权限4、权限5
用户C → 权限1、权限3、权限6
...
问题:
- 如果公司有 100 个员工,每个员工权限不同,需要为每个人单独配置
- 如果某个部门的所有员工都需要新增一个权限,需要修改 50 个用户的配置
- 权限管理混乱,难以维护
✅ RBAC 方式(通过角色分配权限)
角色:管理员
├─ 权限1:文章管理
├─ 权限2:用户管理
└─ 权限3:系统配置
角色:编辑
├─ 权限1:文章管理
└─ 权限4:分类管理
用户A → 角色:管理员
用户B → 角色:编辑
用户C → 角色:编辑
优势:
- 只需要管理角色,不需要为每个用户单独配置
- 新增权限时,只需要修改角色配置,所有拥有该角色的用户自动获得权限
- 权限管理清晰,易于维护
1.3 RBAC 的三种模型
模型 1:RBAC0(基础模型)
最基本的 RBAC 模型,包含:
- 用户(User)
- 角色(Role)
- 权限(Permission)
- 用户-角色关系(User-Role)
- 角色-权限关系(Role-Permission)
我们的博客系统采用的就是 RBAC0 模型。
模型 2:RBAC1(角色继承)
角色可以继承其他角色的权限,形成角色层级。
超级管理员
└─ 继承 → 管理员
└─ 继承 → 编辑
└─ 继承 → 普通用户
模型 3:RBAC2(角色约束)
对角色分配添加约束条件,比如:
- 互斥角色:用户不能同时拥有"管理员"和"审计员"角色
- 基数约束:一个用户最多只能拥有 3 个角色
- 先决条件:用户必须先拥有"编辑"角色,才能获得"高级编辑"角色
二、RBAC 模型的核心概念
2.1 四个核心实体
在我们的博客系统中,RBAC 模型包含四个核心实体:
👤 用户(User)
系统中的实际使用者,比如:
- 张三(管理员)
- 李四(普通用户)
- 王五(编辑)
🎭 角色(Role)
权限的集合,比如:
- 管理员(admin):拥有所有权限
- 编辑(editor):可以管理文章、分类、标签
- 普通用户(user):只能浏览和评论
📋 菜单(Menu)
系统中的功能模块,比如:
- 文章管理
- 用户管理
- 系统设置
每个菜单对应一个权限标识(Permission),比如 blog:article:list(文章列表权限)。
🔗 权限标识(Permission)
权限的标识符,格式通常是:模块:功能:操作
例如:
blog:article:list- 查看文章列表blog:article:add- 添加文章blog:article:delete- 删除文章system:user:list- 查看用户列表
2.2 关系图
用户(User)
↓ 多对多
用户角色表(UserRole)
↓
角色(Role)
↓ 多对多
角色菜单表(RoleMenu)
↓
菜单(Menu)
└─ 权限标识(Permission)
关系说明:
- 一个用户可以拥有多个角色(多对多)
- 一个角色可以分配给多个用户(多对多)
- 一个角色可以拥有多个菜单权限(多对多)
- 一个菜单可以分配给多个角色(多对多)
三、数据库设计:五张表的关系
3.1 五张核心表
我们的博客系统使用五张表来实现 RBAC:
| 表名 | 作用 | 关键字段 |
|---|---|---|
t_user |
用户表 | id, username, password, nickname |
t_role |
角色表 | id, role_name, role_desc, is_disable |
t_menu |
菜单表 | id, menu_name, path, perms, parent_id |
t_user_role |
用户角色关联表 | user_id, role_id |
t_role_menu |
角色菜单关联表 | role_id, menu_id |
3.2 表结构详解
用户表(t_user)
sql
CREATE TABLE `t_user` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '用户id',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '密码(SHA256加密)',
`nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
`avatar` varchar(1024) DEFAULT NULL COMMENT '头像',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
`is_disable` tinyint DEFAULT '0' COMMENT '是否禁用(0否 1是)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键字段说明:
id:用户唯一标识password:密码使用 SHA256 加密存储(不是明文)is_disable:是否被禁用(禁用后无法登录)
角色表(t_role)
sql
CREATE TABLE `t_role` (
`id` varchar(20) NOT NULL COMMENT '角色id',
`role_name` varchar(20) NOT NULL COMMENT '角色名',
`role_desc` varchar(50) DEFAULT NULL COMMENT '角色描述',
`is_disable` tinyint DEFAULT '0' COMMENT '是否禁用(0否 1是)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
示例数据:
sql
INSERT INTO `t_role` VALUES
('1', 'admin', '管理员', 0, NOW()),
('2', 'user', '普通用户', 0, NOW()),
('3', 'editor', '编辑', 0, NOW());
菜单表(t_menu)
sql
CREATE TABLE `t_menu` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '菜单id',
`parent_id` int DEFAULT '0' COMMENT '父菜单id(0表示顶级菜单)',
`menu_type` char(1) NOT NULL COMMENT '类型(M目录 C菜单 B按钮)',
`menu_name` varchar(50) NOT NULL COMMENT '菜单名称',
`path` varchar(200) DEFAULT NULL COMMENT '路由地址',
`icon` varchar(50) DEFAULT NULL COMMENT '菜单图标',
`component` varchar(255) DEFAULT NULL COMMENT '组件路径',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`is_hidden` tinyint DEFAULT '0' COMMENT '是否隐藏(0否 1是)',
`is_disable` tinyint DEFAULT '0' COMMENT '是否禁用(0否 1是)',
`order_num` int DEFAULT '0' COMMENT '排序',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
菜单类型说明:
- M(目录):一级菜单,比如"内容管理"、"系统管理"
- C(菜单):二级菜单,比如"文章管理"、"分类管理"
- B(按钮):页面内的操作按钮,比如"添加文章"、"删除文章"
示例数据:
sql
-- 目录:内容管理
INSERT INTO `t_menu` VALUES
(1, 0, 'M', '内容管理', '/blog', 'edit', 'Layout', NULL, 0, 0, 1, NOW()),
-- 菜单:文章管理
(2, 1, 'C', '文章管理', '/blog/article', 'article', 'blog/article/index', NULL, 0, 0, 1, NOW()),
-- 按钮:添加文章
(3, 2, 'B', '添加文章', NULL, NULL, NULL, 'blog:article:add', 0, 0, 1, NOW()),
-- 按钮:删除文章
(4, 2, 'B', '删除文章', NULL, NULL, NULL, 'blog:article:delete', 0, 0, 2, NOW());
用户角色关联表(t_user_role)
sql
CREATE TABLE `t_user_role` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int NOT NULL COMMENT '用户id',
`role_id` varchar(20) NOT NULL COMMENT '角色id',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
作用:记录用户和角色的多对多关系
示例数据:
sql
-- 用户ID=1 拥有管理员角色(role_id=1)
INSERT INTO `t_user_role` VALUES (1, 1, '1');
-- 用户ID=2 拥有普通用户角色(role_id=2)
INSERT INTO `t_user_role` VALUES (2, 2, '2');
-- 用户ID=3 同时拥有编辑和普通用户角色
INSERT INTO `t_user_role` VALUES (3, 3, '3'), (4, 3, '2');
角色菜单关联表(t_role_menu)
sql
CREATE TABLE `t_role_menu` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`role_id` varchar(20) NOT NULL COMMENT '角色id',
`menu_id` int NOT NULL COMMENT '菜单id',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
作用:记录角色和菜单权限的多对多关系
示例数据:
sql
-- 管理员角色(role_id=1)拥有所有菜单权限
INSERT INTO `t_role_menu` VALUES
(1, '1', 1), -- 内容管理目录
(2, '1', 2), -- 文章管理菜单
(3, '1', 3), -- 添加文章按钮
(4, '1', 4); -- 删除文章按钮
-- 编辑角色(role_id=3)只拥有文章管理相关权限
INSERT INTO `t_role_menu` VALUES
(5, '3', 1), -- 内容管理目录
(6, '3', 2), -- 文章管理菜单
(7, '3', 3); -- 添加文章按钮(但没有删除权限)
3.3 数据关系示例
假设有以下数据:
用户表:
| id | username | nickname |
|---|---|---|
| 1 | admin | 管理员 |
| 2 | zhangsan | 张三 |
| 3 | lisi | 李四 |
角色表:
| id | role_name | role_desc |
|---|---|---|
| 1 | admin | 管理员 |
| 2 | user | 普通用户 |
| 3 | editor | 编辑 |
用户角色关联表:
| user_id | role_id |
|---|---|
| 1 | 1(管理员) |
| 2 | 2(普通用户) |
| 3 | 3(编辑) |
角色菜单关联表:
| role_id | menu_id | 对应权限 |
|---|---|---|
| 1(管理员) | 1-10 | 所有权限 |
| 3(编辑) | 1, 2, 3 | 文章管理、添加文章 |
查询用户权限的 SQL:
sql
-- 查询用户ID=3(李四)的所有权限
SELECT DISTINCT m.perms
FROM t_user_role ur
JOIN t_role_menu rm ON ur.role_id = rm.role_id
JOIN t_menu m ON rm.menu_id = m.id
WHERE ur.user_id = 3
AND m.perms IS NOT NULL;
结果:
blog:article:list
blog:article:add
四、Sa-Token 认证框架简介
4.1 什么是 Sa-Token?
Sa-Token 是一个轻量级 Java 权限认证框架,主要解决登录认证、权限认证、单点登录、OAuth2、微服务网关鉴权等一系列权限相关问题。
4.2 为什么选择 Sa-Token?
对比其他框架
| 框架 | 优点 | 缺点 |
|---|---|---|
| Spring Security | 功能强大、生态完善 | 配置复杂、学习曲线陡峭 |
| Shiro | 功能全面、文档丰富 | 配置繁琐、与 Spring Boot 集成需要额外配置 |
| Sa-Token | 简单易用、零配置、功能强大 | 相对较新,社区较小 |
Sa-Token 的优势
- 简单易用:API 设计简洁,几行代码就能完成登录认证
- 零配置:开箱即用,不需要复杂的 XML 配置
- 功能强大:支持 RBAC、多账号体系、踢人下线、账号封禁等
- Redis 集成:天然支持 Redis,适合分布式部署
4.3 Sa-Token 核心概念
Token(令牌)
用户登录后,Sa-Token 会生成一个 Token(类似身份证),保存在:
- 前端 :
localStorage或sessionStorage - 后端:Redis(如果配置了 Redis)
Token 格式 :satoken:login:account:xxxx-xxxx-xxxx-xxxx
Session(会话)
每个 Token 对应一个 Session,Session 中可以存储:
- 用户信息
- 角色列表
- 权限列表
- 自定义数据
登录类型(LoginType)
Sa-Token 支持多账号体系,可以区分:
admin:管理员登录user:普通用户登录client:客户端登录
我们的博客系统使用两种登录类型:
admin:后台管理员登录client:前台用户登录
五、登录认证流程详解
5.1 登录流程图
用户输入用户名密码
↓
前端发送 POST /admin/login
↓
后端验证用户名和密码
↓
密码正确?
├─ 否 → 返回"用户名或密码错误"
└─ 是 ↓
检查账号是否被禁用
↓
调用 StpUtil.login(userId) 生成 Token
↓
查询用户的角色列表
↓
检查是否为管理员角色
├─ 否 → 退出登录,返回"权限不足"
└─ 是 ↓
将角色列表存入 Session
↓
返回 Token 给前端
↓
前端保存 Token 到 localStorage
↓
后续请求携带 Token
5.2 登录代码实现
后端登录接口
java
@PostMapping("/admin/login")
public Result<String> adminLogin(@RequestBody LoginReq login) {
// 1. 查询用户
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.select(User::getId)
.eq(User::getUsername, login.getUsername())
.eq(User::getPassword, SecurityUtils.sha256Encrypt(login.getPassword())));
// 2. 验证用户是否存在
Assert.notNull(user, "用户不存在或密码错误");
// 3. 检查账号是否被禁用
StpUtil.checkDisable(user.getId());
// 4. 登录(生成 Token)
StpUtil.login(user.getId());
// 5. 获取角色列表
List<String> roleList = StpUtil.getRoleList();
// 6. 检查是否为管理员
if (!roleList.contains(RoleEnum.ADMIN.getRoleId())) {
StpUtil.logout(); // 不是管理员,退出登录
throw new RuntimeException("权限不足,仅管理员可登录后台");
}
// 7. 返回 Token
return Result.success(StpUtil.getTokenValue());
}
密码加密
为什么密码要加密?
如果数据库中存储明文密码:
sql
-- ❌ 危险!明文密码
username: admin
password: 123456
一旦数据库泄露,攻击者可以直接登录。
使用 SHA256 加密:
java
// 注册时加密密码
String encryptedPassword = SecurityUtils.sha256Encrypt("123456");
// 结果:8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
// 登录时比较加密后的密码
String inputPassword = SecurityUtils.sha256Encrypt(login.getPassword());
if (inputPassword.equals(user.getPassword())) {
// 密码正确
}
⚠️ 注意 :SHA256 是单向加密(哈希),无法解密。只能通过比较加密后的字符串来判断密码是否正确。
5.3 Token 生成与存储
Token 生成
java
// 登录
StpUtil.login(userId);
// Sa-Token 内部流程:
// 1. 生成 Token(UUID)
// 2. 将 Token 和 userId 的映射关系存入 Redis
// 3. 创建 Session,存储用户信息
// 4. 返回 Token
Redis 存储结构
Key: satoken:login:account:xxxx-xxxx-xxxx-xxxx
Value: {
"loginId": 1,
"loginType": "admin",
"tokenTimeout": 1800,
"sessionTimeout": 1800
}
Key: satoken:session:account:1
Value: {
"Role_List": ["1"],
"Permission_List": ["blog:article:list", "blog:article:add", ...]
}
Token 有效期
java
// 默认 Token 有效期:30 分钟
// 如果用户在 30 分钟内没有操作,Token 会过期
// 自动续期:如果 Token 剩余时间少于 10 分钟,自动续期到 30 分钟
if (StpUtil.getTokenTimeout() < 600) {
StpUtil.renewTimeout(1800); // 续期到 30 分钟
}
5.4 前端登录实现
登录请求
typescript
// 发送登录请求
const login = async (username: string, password: string) => {
const response = await axios.post('/api/admin/login', {
username,
password
});
// 保存 Token
if (response.data.flag) {
localStorage.setItem('Admin-Token', response.data.data);
// 跳转到后台首页
router.push('/');
}
};
Token 携带
typescript
// 配置 Axios 拦截器,自动在请求头中携带 Token
axios.interceptors.request.use(config => {
const token = localStorage.getItem('Admin-Token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
六、权限校验流程详解
6.1 权限校验流程图
用户访问接口 /admin/article/list
↓
请求到达后端,携带 Token
↓
Sa-Token 拦截器检查 Token
↓
Token 有效?
├─ 否 → 返回 401 Unauthorized
└─ 是 ↓
从 Token 获取 userId
↓
查询用户的角色列表(从 Session 或数据库)
↓
查询角色对应的权限列表(从 Session 或数据库)
↓
检查接口需要的权限(@SaCheckPermission)
↓
用户有该权限?
├─ 否 → 返回 403 Forbidden
└─ 是 ↓
执行接口逻辑
↓
返回结果
6.2 权限校验代码实现
方式一:注解式权限校验(推荐)
java
@RestController
public class ArticleController {
// 查看文章列表 - 需要 blog:article:list 权限
@SaCheckPermission("blog:article:list")
@GetMapping("/admin/article/list")
public Result<PageResult<ArticleResp>> listArticle(ArticleQuery query) {
// 业务逻辑
return Result.success(articleService.listArticle(query));
}
// 添加文章 - 需要 blog:article:add 权限
@SaCheckPermission("blog:article:add")
@PostMapping("/admin/article/add")
public Result<?> addArticle(@RequestBody ArticleReq article) {
articleService.addArticle(article);
return Result.success();
}
// 删除文章 - 需要 blog:article:delete 权限
@SaCheckPermission("blog:article:delete")
@DeleteMapping("/admin/article/delete")
public Result<?> deleteArticle(@RequestBody List<Integer> ids) {
articleService.deleteArticle(ids);
return Result.success();
}
}
方式二:编程式权限校验
java
@GetMapping("/admin/article/list")
public Result<PageResult<ArticleResp>> listArticle(ArticleQuery query) {
// 检查是否有权限
StpUtil.checkPermission("blog:article:list");
// 业务逻辑
return Result.success(articleService.listArticle(query));
}
方式三:角色校验
java
// 检查是否为管理员角色(role_id = "1")
@SaCheckRole("1")
@GetMapping("/admin/user/list")
public Result<PageResult<UserResp>> listUser() {
return Result.success(userService.listUser());
}
6.3 权限获取流程(核心代码)
StpInterfaceImpl.java
这是 Sa-Token 的核心接口实现,负责获取用户的角色和权限:
java
@Component
public class StpInterfaceImpl implements StpInterface {
@Autowired
private MenuMapper menuMapper;
@Autowired
private RoleMapper roleMapper;
/**
* 获取用户的权限列表
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
List<String> permissionList = new ArrayList<>();
// 1. 获取用户的所有角色
List<String> roleList = getRoleList(loginId, loginType);
// 2. 遍历每个角色,获取权限
for (String roleId : roleList) {
// 3. 从 Session 缓存中获取权限(如果存在)
SaSession roleSession = SaSessionCustomUtil.getSessionById("role-" + roleId);
List<String> perms = roleSession.get("Permission_List", () -> {
// 4. 如果缓存不存在,从数据库查询
return menuMapper.selectPermissionByRoleId(roleId);
});
permissionList.addAll(perms);
}
return permissionList;
}
/**
* 获取用户的角色列表
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 1. 获取用户的 Session
SaSession session = StpUtil.getSessionByLoginId(loginId);
// 2. 从 Session 缓存中获取角色列表(如果存在)
return session.get("Role_List", () -> {
// 3. 如果缓存不存在,从数据库查询
return roleMapper.selectRoleListByUserId(loginId);
});
}
}
关键点:
- 缓存机制:优先从 Session 缓存中获取,减少数据库查询
- 懒加载:只有在需要时才查询数据库
- 多角色支持:用户可以拥有多个角色,权限会合并
6.4 全局权限拦截器
SaTokenConfig.java
java
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
// 拦截所有路径
.addInclude("/**")
// 排除不需要登录的路径
.addExclude("/admin/login", "/client/login", "/oauth/*")
// 认证函数:每次请求都会执行
.setAuth(obj -> {
// 1. 检查 /admin/** 路径是否需要登录
SaRouter.match("/admin/**").check(r -> StpUtil.checkLogin());
// 2. Token 自动续期(如果剩余时间少于 10 分钟)
if (StpUtil.getTokenTimeout() < 600) {
StpUtil.renewTimeout(1800); // 续期到 30 分钟
}
})
// 异常处理
.setError(e -> {
if (e instanceof NotLoginException) {
return JSONUtil.toJsonStr(Result.fail(401, "未登录"));
}
return SaResult.error(e.getMessage());
});
}
工作流程:
- 用户请求
/admin/article/list - 拦截器检查路径是否匹配
/admin/** - 调用
StpUtil.checkLogin()检查是否登录 - 如果未登录,抛出
NotLoginException - 如果已登录,继续执行,检查权限注解
七、后端权限控制实现
7.1 权限注解详解
@SaCheckLogin
作用:检查用户是否已登录
java
@SaCheckLogin
@GetMapping("/admin/user/info")
public Result<UserInfoResp> getUserInfo() {
// 获取当前登录用户ID
Integer userId = StpUtil.getLoginIdAsInt();
return Result.success(userService.getUserInfo(userId));
}
使用场景:只需要登录就能访问的接口,不区分权限
@SaCheckPermission
作用:检查用户是否有指定权限
java
// 单个权限
@SaCheckPermission("blog:article:list")
@GetMapping("/admin/article/list")
public Result<PageResult<ArticleResp>> listArticle() {
// ...
}
// 多个权限(AND 关系:必须同时拥有)
@SaCheckPermission({"blog:article:add", "blog:article:edit"})
@PostMapping("/admin/article/update")
public Result<?> updateArticle() {
// ...
}
// 多个权限(OR 关系:拥有其中一个即可)
@SaCheckPermission(value = {"blog:article:add", "blog:article:edit"}, mode = SaMode.OR)
@PostMapping("/admin/article/save")
public Result<?> saveArticle() {
// ...
}
@SaCheckRole
作用:检查用户是否有指定角色
java
// 单个角色
@SaCheckRole("1") // 必须是管理员
@GetMapping("/admin/user/list")
public Result<PageResult<UserResp>> listUser() {
// ...
}
// 多个角色(OR 关系)
@SaCheckRole(value = {"1", "3"}, mode = SaMode.OR)
@GetMapping("/admin/article/list")
public Result<PageResult<ArticleResp>> listArticle() {
// ...
}
7.2 实际应用示例
文章管理接口
java
@RestController
@RequestMapping("/admin/article")
public class ArticleController {
// 1. 查看文章列表 - 需要权限
@SaCheckPermission("blog:article:list")
@GetMapping("/list")
public Result<PageResult<ArticleResp>> listArticle(ArticleQuery query) {
return Result.success(articleService.listArticle(query));
}
// 2. 添加文章 - 需要权限
@SaCheckPermission("blog:article:add")
@PostMapping("/add")
public Result<?> addArticle(@RequestBody ArticleReq article) {
articleService.addArticle(article);
return Result.success();
}
// 3. 删除文章 - 需要权限
@SaCheckPermission("blog:article:delete")
@DeleteMapping("/delete")
public Result<?> deleteArticle(@RequestBody List<Integer> ids) {
articleService.deleteArticle(ids);
return Result.success();
}
// 4. 点赞文章 - 只需要登录
@SaCheckLogin
@PostMapping("/like")
public Result<?> likeArticle(@RequestParam Integer articleId) {
Integer userId = StpUtil.getLoginIdAsInt();
articleService.likeArticle(articleId, userId);
return Result.success();
}
}
用户管理接口
java
@RestController
@RequestMapping("/admin/user")
public class UserController {
// 1. 查看用户列表 - 需要权限
@SaCheckPermission("system:user:list")
@GetMapping("/list")
public Result<PageResult<UserResp>> listUser(UserQuery query) {
return Result.success(userService.listUser(query));
}
// 2. 修改用户角色 - 需要权限
@SaCheckPermission("system:user:update")
@PutMapping("/role")
public Result<?> updateUserRole(@RequestBody UserRoleReq req) {
userService.updateUserRole(req);
return Result.success();
}
// 3. 踢用户下线 - 必须是管理员角色
@SaCheckRole("1")
@PostMapping("/kick/{token}")
public Result<?> kickUser(@PathVariable String token) {
StpUtil.kickoutByTokenValue(token);
return Result.success();
}
}
7.3 权限校验失败处理
全局异常处理
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotLoginException.class)
public Result<?> handleNotLoginException(NotLoginException e) {
return Result.fail(401, "未登录,请先登录");
}
@ExceptionHandler(NotPermissionException.class)
public Result<?> handleNotPermissionException(NotPermissionException e) {
return Result.fail(403, "权限不足:" + e.getPermission());
}
@ExceptionHandler(NotRoleException.class)
public Result<?> handleNotRoleException(NotRoleException e) {
return Result.fail(403, "角色不足:" + e.getRole());
}
}
八、前端权限控制实现
8.1 路由权限控制
路由守卫(permission.ts)
typescript
import router from '@/router';
import { getToken } from '@/utils/token';
import { useStore } from '@/store';
// 白名单:不需要登录就能访问的页面
const whiteList = ['/login'];
router.beforeEach(async (to, from, next) => {
const store = useStore();
const { user, permission } = store;
// 1. 检查是否有 Token
if (getToken()) {
// 2. 如果访问登录页,跳转到首页
if (to.path === '/login') {
next({ path: '/' });
return;
}
// 3. 如果用户信息未加载,先加载用户信息和菜单
if (user.roleList.length === 0) {
try {
// 获取用户信息(包含角色列表)
await user.GetInfo();
// 根据角色生成可访问的路由
const accessRoutes = await permission.generateRoutes();
// 动态添加路由
accessRoutes.forEach(route => {
router.addRoute(route);
});
// 跳转到目标页面
next({ ...to, replace: true });
} catch (error) {
// 获取用户信息失败,退出登录
await user.LogOut();
next({ path: '/login' });
}
} else {
// 用户信息已加载,直接放行
next();
}
} else {
// 没有 Token
if (whiteList.includes(to.path)) {
// 在白名单中,直接放行
next();
} else {
// 不在白名单中,跳转到登录页
next({ path: '/login', query: { redirect: to.path } });
}
}
});
8.2 菜单权限控制
获取用户菜单(permission.ts)
typescript
const usePermissionStore = defineStore({
id: 'permission',
state: () => ({
routes: [] // 可访问的路由列表
}),
actions: {
// 根据用户角色生成可访问的路由
async generateRoutes(): Promise<RouteRecordRaw[]> {
return new Promise((resolve, reject) => {
// 调用后端接口获取用户菜单
getUserMenu()
.then(({ data }) => {
if (data.flag) {
// 后端返回的菜单树
const asyncRoutes = data.data;
// 转换为 Vue Router 格式
const accessedRoutes = filterAsyncRoutes(asyncRoutes);
// 保存到 state
this.setRoutes(accessedRoutes);
resolve(accessedRoutes);
}
})
.catch(error => {
reject(error);
});
});
}
}
});
后端返回的菜单结构
json
{
"flag": true,
"data": [
{
"id": 1,
"menuName": "内容管理",
"path": "/blog",
"icon": "edit",
"component": "Layout",
"children": [
{
"id": 2,
"menuName": "文章管理",
"path": "/blog/article",
"component": "blog/article/index",
"children": [
{
"id": 3,
"menuName": "添加文章",
"perms": "blog:article:add"
}
]
}
]
}
]
}
8.3 按钮权限控制
使用 v-if 控制按钮显示
vue
<template>
<div>
<!-- 只有拥有 blog:article:add 权限的用户才能看到"添加文章"按钮 -->
<el-button
v-if="hasPermission('blog:article:add')"
@click="handleAdd"
>
添加文章
</el-button>
<!-- 只有拥有 blog:article:delete 权限的用户才能看到"删除"按钮 -->
<el-button
v-if="hasPermission('blog:article:delete')"
type="danger"
@click="handleDelete"
>
删除
</el-button>
</div>
</template>
<script setup>
import { useStore } from '@/store';
const store = useStore();
// 检查是否有权限
const hasPermission = (permission: string) => {
const permissionList = store.user.permissionList || [];
return permissionList.includes(permission);
};
</script>
使用自定义指令(更优雅)
typescript
// directives/permission.ts
import { useStore } from '@/store';
export default {
mounted(el: HTMLElement, binding: any) {
const store = useStore();
const permissionList = store.user.permissionList || [];
const value = binding.value;
// 如果没有权限,隐藏元素
if (!permissionList.includes(value)) {
el.style.display = 'none';
// 或者直接移除元素
// el.parentNode?.removeChild(el);
}
}
};
vue
<template>
<div>
<!-- 使用指令控制按钮显示 -->
<el-button v-permission="'blog:article:add'" @click="handleAdd">
添加文章
</el-button>
<el-button v-permission="'blog:article:delete'" type="danger" @click="handleDelete">
删除
</el-button>
</div>
</template>
九、安全措施与最佳实践
9.1 密码安全
✅ 密码加密存储
java
// ❌ 错误:明文存储
user.setPassword("123456");
// ✅ 正确:SHA256 加密
user.setPassword(SecurityUtils.sha256Encrypt("123456"));
✅ 密码强度验证
java
// 前端验证密码强度
const validatePassword = (password: string) => {
// 至少 8 位,包含字母和数字
const regex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$/;
return regex.test(password);
};
✅ 防止暴力破解
java
// 限制登录失败次数
@Autowired
private RedisService redisService;
public String login(LoginReq login) {
String key = "login:fail:" + login.getUsername();
Integer failCount = redisService.getObject(key);
// 如果失败次数超过 5 次,锁定 30 分钟
if (failCount != null && failCount >= 5) {
throw new RuntimeException("账号已被锁定,请 30 分钟后重试");
}
try {
// 登录逻辑
User user = validateUser(login);
StpUtil.login(user.getId());
// 登录成功,清除失败计数
redisService.deleteObject(key);
return StpUtil.getTokenValue();
} catch (Exception e) {
// 登录失败,增加失败计数
redisService.setObject(key, (failCount == null ? 0 : failCount) + 1, 30, TimeUnit.MINUTES);
throw e;
}
}
9.2 Token 安全
✅ Token 过期时间
java
// 设置 Token 有效期为 30 分钟
StpUtil.login(userId);
StpUtil.setTokenTimeout(1800); // 30 分钟 = 1800 秒
✅ Token 自动续期
java
// 如果 Token 剩余时间少于 10 分钟,自动续期到 30 分钟
if (StpUtil.getTokenTimeout() < 600) {
StpUtil.renewTimeout(1800);
}
✅ 单点登录(同一账号只能在一个地方登录)
java
// 登录时,踢掉其他地方的登录
StpUtil.login(userId, "PC"); // 指定设备类型
// 如果同一账号在其他设备登录,会踢掉当前设备的登录
✅ 强制下线
java
// 管理员可以强制用户下线
@SaCheckRole("1")
@PostMapping("/admin/user/kick/{token}")
public Result<?> kickUser(@PathVariable String token) {
StpUtil.kickoutByTokenValue(token);
return Result.success();
}
9.3 账号安全
✅ 账号封禁
java
// 封禁账号(24 小时)
StpUtil.disable(userId, 86400);
// 解封账号
StpUtil.untieDisable(userId);
// 检查账号是否被封禁
StpUtil.checkDisable(userId); // 如果被封禁,会抛出异常
✅ 登录日志记录
java
@Component
public class MySaTokenListener implements SaTokenListener {
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
// 记录登录日志
String ipAddress = ServletUtil.getClientIP(request);
String ipSource = IpUtils.getIpSource(ipAddress);
// 保存到数据库或日志文件
log.info("用户 {} 登录成功,IP: {},来源: {}", loginId, ipAddress, ipSource);
}
@Override
public void doLogout(String loginType, Object loginId, String tokenValue) {
// 记录登出日志
log.info("用户 {} 退出登录", loginId);
}
}
9.4 接口安全
✅ 防止 SQL 注入
java
// ❌ 错误:拼接 SQL
String sql = "SELECT * FROM t_user WHERE username = '" + username + "'";
// ✅ 正确:使用 MyBatis-Plus 参数化查询
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));
✅ 防止 XSS 攻击
java
// 前端:对用户输入进行转义
const escapeHtml = (text: string) => {
const map: { [key: string]: string } = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
};
// 后端:使用 Hutool 工具类
String safeContent = HtmlUtil.escape(articleContent);
✅ 接口限流
java
// 使用 Redis 实现接口限流
@Component
public class AccessLimitInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String key = "limit:" + request.getRequestURI() + ":" + getClientIp(request);
Integer count = redisService.getObject(key);
if (count == null) {
redisService.setObject(key, 1, 60, TimeUnit.SECONDS);
} else if (count >= 100) {
// 1 分钟内超过 100 次请求,拒绝访问
throw new RuntimeException("请求过于频繁,请稍后再试");
} else {
redisService.setObject(key, count + 1, 60, TimeUnit.SECONDS);
}
return true;
}
}
9.5 最佳实践总结
| 安全措施 | 实现方式 | 重要性 |
|---|---|---|
| 密码加密 | SHA256 加密存储 | ⭐⭐⭐⭐⭐ |
| Token 过期 | 设置合理的过期时间 | ⭐⭐⭐⭐⭐ |
| Token 续期 | 自动续期,提升用户体验 | ⭐⭐⭐⭐ |
| 登录限制 | 防止暴力破解 | ⭐⭐⭐⭐ |
| 账号封禁 | 支持临时封禁账号 | ⭐⭐⭐⭐ |
| SQL 注入防护 | 使用参数化查询 | ⭐⭐⭐⭐⭐ |
| XSS 防护 | 输入转义、输出过滤 | ⭐⭐⭐⭐ |
| 接口限流 | Redis 计数器限流 | ⭐⭐⭐ |
| 登录日志 | 记录登录 IP、时间等信息 | ⭐⭐⭐ |
十、常见问题与解决方案
10.1 Token 过期问题
问题:用户操作一段时间后,突然提示"未登录"
原因:Token 过期时间设置太短,或者没有自动续期
解决方案:
java
// 1. 增加 Token 过期时间
StpUtil.setTokenTimeout(3600); // 1 小时
// 2. 启用自动续期(在拦截器中)
if (StpUtil.getTokenTimeout() < 600) {
StpUtil.renewTimeout(1800);
}
// 3. 前端:Token 过期后自动跳转登录页
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// Token 过期,清除本地 Token,跳转登录页
localStorage.removeItem('Admin-Token');
router.push('/login');
}
return Promise.reject(error);
}
);
10.2 权限缓存问题
问题:修改用户角色后,权限没有立即生效
原因:权限列表缓存在 Session 中,修改角色后没有清除缓存
解决方案:
java
// 修改用户角色时,清除权限缓存
public void updateUserRole(UserRoleReq req) {
// 1. 更新数据库
userRoleMapper.updateUserRole(req);
// 2. 清除用户的 Session 缓存
SaSession session = StpUtil.getSessionByLoginId(req.getUserId(), false);
if (session != null) {
session.delete("Role_List");
session.delete("Permission_List");
}
// 3. 如果用户在线,强制重新登录
StpUtil.logout(req.getUserId());
}
10.3 跨域问题
问题:前端请求接口时出现 CORS 错误
解决方案:
java
// 在 SaTokenConfig 中配置 CORS
.setBeforeAuth(obj -> {
SaHolder.getResponse()
.setHeader("Access-Control-Allow-Origin", "*")
.setHeader("Access-Control-Allow-Methods", "*")
.setHeader("Access-Control-Allow-Headers", "*");
});
10.4 权限不足问题
问题:用户明明有角色,但提示"权限不足"
排查步骤:
- 检查角色是否被禁用
sql
SELECT * FROM t_role WHERE id = '1' AND is_disable = 0;
- 检查用户是否拥有该角色
sql
SELECT * FROM t_user_role WHERE user_id = 1 AND role_id = '1';
- 检查角色是否拥有该权限
sql
SELECT m.perms
FROM t_role_menu rm
JOIN t_menu m ON rm.menu_id = m.id
WHERE rm.role_id = '1' AND m.perms = 'blog:article:list';
- 检查权限标识是否拼写正确
java
// 确保注解中的权限标识和数据库中的一致
@SaCheckPermission("blog:article:list") // 注意大小写和格式
10.5 前端菜单不显示问题
问题:登录后,菜单没有显示
排查步骤:
- 检查后端接口是否返回菜单数据
typescript
// 在浏览器控制台查看
console.log(await getUserMenu());
- 检查路由是否动态添加
typescript
// 在路由守卫中打印
console.log('可访问路由:', accessRoutes);
router.addRoute(route);
- 检查菜单组件是否正确渲染
vue
<template>
<el-menu>
<el-menu-item
v-for="route in routes"
:key="route.path"
:index="route.path"
>
{{ route.menuName }}
</el-menu-item>
</el-menu>
</template>
十一、总结
11.1 RBAC 模型优势
- 灵活性:通过角色管理权限,不需要为每个用户单独配置
- 可维护性:权限变更时,只需要修改角色配置
- 可扩展性:可以轻松添加新角色和新权限
- 安全性:权限控制集中管理,减少安全漏洞
11.2 Sa-Token 框架优势
- 简单易用:API 简洁,学习成本低
- 功能强大:支持 RBAC、多账号体系、单点登录等
- 性能优秀:支持 Redis 缓存,适合分布式部署
- 文档完善:官方文档详细,社区活跃
11.3 安全建议
- 密码安全:使用强加密算法(SHA256),验证密码强度
- Token 安全:设置合理的过期时间,启用自动续期
- 接口安全:防止 SQL 注入、XSS 攻击,实施接口限流
- 日志记录:记录登录日志、操作日志,便于审计
11.4 学习资源
- Sa-Token 官方文档:https://sa-token.cc/
- RBAC 模型详解:https://en.wikipedia.org/wiki/Role-based_access_control
- Spring Security 对比:https://spring.io/projects/spring-security
希望这篇教程能帮助你理解和实现 RBAC 权限模型!如果遇到问题,欢迎查阅官方文档或社区资源。