博客RBAC权限模型与安全认证全解析

博客 RBAC 权限模型与安全认证详解

本文详细介绍个人博客系统中基于 RBAC(基于角色的访问控制)模型的权限管理机制,以及使用 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 的优势
  1. 简单易用:API 设计简洁,几行代码就能完成登录认证
  2. 零配置:开箱即用,不需要复杂的 XML 配置
  3. 功能强大:支持 RBAC、多账号体系、踢人下线、账号封禁等
  4. Redis 集成:天然支持 Redis,适合分布式部署

4.3 Sa-Token 核心概念

Token(令牌)

用户登录后,Sa-Token 会生成一个 Token(类似身份证),保存在:

  • 前端localStoragesessionStorage
  • 后端: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);
        });
    }
}

关键点

  1. 缓存机制:优先从 Session 缓存中获取,减少数据库查询
  2. 懒加载:只有在需要时才查询数据库
  3. 多角色支持:用户可以拥有多个角色,权限会合并

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());
            });
}

工作流程

  1. 用户请求 /admin/article/list
  2. 拦截器检查路径是否匹配 /admin/**
  3. 调用 StpUtil.checkLogin() 检查是否登录
  4. 如果未登录,抛出 NotLoginException
  5. 如果已登录,继续执行,检查权限注解

七、后端权限控制实现

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 } = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  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 权限不足问题

问题:用户明明有角色,但提示"权限不足"

排查步骤

  1. 检查角色是否被禁用
sql 复制代码
SELECT * FROM t_role WHERE id = '1' AND is_disable = 0;
  1. 检查用户是否拥有该角色
sql 复制代码
SELECT * FROM t_user_role WHERE user_id = 1 AND role_id = '1';
  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';
  1. 检查权限标识是否拼写正确
java 复制代码
// 确保注解中的权限标识和数据库中的一致
@SaCheckPermission("blog:article:list")  // 注意大小写和格式

10.5 前端菜单不显示问题

问题:登录后,菜单没有显示

排查步骤

  1. 检查后端接口是否返回菜单数据
typescript 复制代码
// 在浏览器控制台查看
console.log(await getUserMenu());
  1. 检查路由是否动态添加
typescript 复制代码
// 在路由守卫中打印
console.log('可访问路由:', accessRoutes);
router.addRoute(route);
  1. 检查菜单组件是否正确渲染
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 模型优势

  1. 灵活性:通过角色管理权限,不需要为每个用户单独配置
  2. 可维护性:权限变更时,只需要修改角色配置
  3. 可扩展性:可以轻松添加新角色和新权限
  4. 安全性:权限控制集中管理,减少安全漏洞

11.2 Sa-Token 框架优势

  1. 简单易用:API 简洁,学习成本低
  2. 功能强大:支持 RBAC、多账号体系、单点登录等
  3. 性能优秀:支持 Redis 缓存,适合分布式部署
  4. 文档完善:官方文档详细,社区活跃

11.3 安全建议

  1. 密码安全:使用强加密算法(SHA256),验证密码强度
  2. Token 安全:设置合理的过期时间,启用自动续期
  3. 接口安全:防止 SQL 注入、XSS 攻击,实施接口限流
  4. 日志记录:记录登录日志、操作日志,便于审计

11.4 学习资源


希望这篇教程能帮助你理解和实现 RBAC 权限模型!如果遇到问题,欢迎查阅官方文档或社区资源。

相关推荐
wfsm2 小时前
有向图的状态转换
数据库
IMdive3 小时前
OpenHarmony鸿蒙远程数据库连接应用开发指南
数据库·华为·harmonyos
筵陌3 小时前
MySQL事务管理(上)
数据库·mysql
数据知道3 小时前
PostgreSQL:详解 orafce 拓展插件的使用
数据库·postgresql
xj198603193 小时前
MySql-9.1.0安装详细教程(保姆级)
数据库·mysql
·云扬·3 小时前
【MySQL】主从复制:原理、作用与实现方法
数据库·mysql
数据知道3 小时前
PostgreSQL:详解 MySQL数据迁移,如何将数据平滑迁移到PostgreSQL
数据库·mysql·postgresql
枷锁—sha3 小时前
【CTFshow-pwn系列】03_栈溢出【pwn 047】详解:Ret2Libc 之 已知关键地址
网络·安全·网络安全
番茄去哪了3 小时前
在Java中操作Redis
java·开发语言·数据库·redis