目录
一、简介与配置
二、实现登录注销操作
三、权限认证构成与鉴权方法
四、RBAC 结构实现 <— 你在这里 ( •̀ ω •́ )y

Sa-Token(四)RBAC 结构实现

[!NOTE]

博主前言:Spring Security 的学习真是惊掉我下巴,就一个鉴权功能,搞那么多复杂概念,又是过滤器链又是注入的,花了我一个星期左右,实际上还很不好用(未知错误给前端返回 401 是真绷不住)。Sa-Token 作为国产鉴权,性能更强的同时简化开发的程度相比 Spring Security 起码有十倍不止。最近我还注意到对标 Spring 生态的 Solon 框架,也是国产,性能提升3倍多,内存占用减少 50%+,打包还更小,真不知道这些工作为什么老外都做的这么复杂。

了解了 Sa-Token 权限认证的构造,现在我们来动手实现。

基于经典的RBAC结构实现。


一、准备数据模型

为了实现RBAC结构,除了第二篇文章的用户(user),还需准备三个新表,以下为这四个表的结构:

[!NOTE]

我写这篇文档的同时也在编写 Sa-Token 的快速模板,所以用户的结构重构了一下,添加了时间相关字段

  1. user
字段名 数据类型 字符集与排序规则 是否允许为空 默认值 主键 唯一键 注释
id bigint - - - 主键
account varchar(64) utf8mb4, utf8mb4_zh_0900_as_cs - - 账号
password varchar(64) utf8mb4, utf8mb4_zh_0900_as_cs - - - 密码
create_time datetime - - - - 创建时间
update_time datetime - - - - 最近修改时间
last_login_time datetime - - - - 上一次登录时间
  1. role
字段名 数据类型 字符集与排序规则 是否允许为空 默认值 主键 唯一键 注释
id bigint - - - 主键
name varchar(64) utf8mb4, utf8mb4_zh_0900_as_cs - - 角色名
value varchar(64) utf8mb4, utf8mb4_zh_0900_as_cs - - - 角色字段
create_time datetime - - - - 创建时间
update_time datetime - - - - 最近修改时间
  1. lk_user_role
字段名 数据类型 字符集与排序规则 是否允许为空 默认值 主键 外键 注释
id bigint - - - 主键
user_id bigint - - - user(id) 用户主键
role_id bigint - - - role(id) 角色主键
  1. permission
字段名 数据类型 字符集与排序规则 是否允许为空 默认值 主键 外键 注释
id bigint - - - 主键
role_id bigint - - - role(id) 角色主键
value varchar(32) utf8mb4, utf8mb4_zh_0900_as_cs - - - 权限字段

建表sql代码:

[!NOTE]

附带创建的用户密码均为:password

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
CREATE TABLE `user` (
`id` bigint NOT NULL COMMENT '主键',
`account` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_zh_0900_as_cs NOT NULL COMMENT '账号',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_zh_0900_as_cs NOT NULL COMMENT '密码',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '最近修改时间',
`last_login_time` datetime NOT NULL COMMENT '上一次登录时间,初始为注册时间',
PRIMARY KEY (`id`),
UNIQUE KEY `account_unique` (`account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_zh_0900_as_cs COMMENT='测试用户';

CREATE TABLE `role` (
`id` bigint NOT NULL COMMENT '主键',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_zh_0900_as_cs NOT NULL COMMENT '角色名',
`value` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_zh_0900_as_cs NOT NULL COMMENT '角色字段',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '最近修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `name_unique` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_zh_0900_as_cs COMMENT='角色';

CREATE TABLE `lk_user_role` (
`id` bigint NOT NULL COMMENT '主键',
`user_id` bigint NOT NULL COMMENT '用户主键',
`role_id` bigint NOT NULL COMMENT '角色主键',
PRIMARY KEY (`id`),
KEY `user` (`user_id`),
KEY `role` (`role_id`),
CONSTRAINT `lk_user_role_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
CONSTRAINT `lk_user_role_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_zh_0900_as_cs COMMENT='用户角色链接键';

CREATE TABLE `permission` (
`id` bigint NOT NULL COMMENT '主键',
`role_id` bigint NOT NULL COMMENT '角色主键',
`value` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_zh_0900_as_cs NOT NULL COMMENT '权限字段',
PRIMARY KEY (`id`),
KEY `role_id` (`role_id`),
CONSTRAINT `permission_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_zh_0900_as_cs COMMENT='用户权限';

INSERT INTO `user` VALUES (1, 'admin', '$2a$10$3/ET98bJ6QiZIpjMx9J52OhXwj.Odd1jTFXky7aFz/ZqAcBUUePdS', '2025-02-11 07:58:10', '2025-02-11 07:58:13', '2025-02-11 07:58:16');
INSERT INTO `user` VALUES (2, 'user', '$2a$10$3/ET98bJ6QiZIpjMx9J52OhXwj.Odd1jTFXky7aFz/ZqAcBUUePdS', '2025-02-06 01:09:18', '2025-02-11 07:57:29', '2025-02-11 07:28:32');
INSERT INTO `role` VALUES (1, '管理员', 'admin', '2025-02-06 14:35:53', '2025-02-06 14:35:56');
INSERT INTO `role` VALUES (2, '用户', 'user', '2025-02-06 14:36:07', '2025-02-06 14:36:11');
INSERT INTO `lk_user_role` VALUES (1, 1, 1);
INSERT INTO `lk_user_role` VALUES (2, 2, 2);
INSERT INTO `permission`(1, 2, "user:info");

对应的数据结构类:

[!IMPORTANT]

我创建了BaseEntityOnlyCreateTimeEntityFullTimeEntity被数据结构类继承,用于通用地补全idcreate_timeupdate_time这三个字段。

LkUserRole作为多对多链接键模型类,我对其做了一些特殊处理,读者可以不做,但是对应代码届时需要自行修改补充,详见:代码层多对多结构的通用处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
/**
* 数据结构基类
*
* @author Amane64
*/
@Data
public class BaseEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

/**
* 主键
*/
@TableId
private Long id;
}

/**
* 数据结构基类(包含创建时间)
*
* @author Amane64
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class OnlyCreateTimeEntity extends BaseEntity {
/**
* 创建时间
*/
@JSONField(format="yyyy-MM-dd HH:mm:ss")
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}
/**
* 数据结构基类(包含全部属性)
*
* @author Amane64
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class FullTimeEntity extends OnlyCreateTimeEntity {
/**
* 最近修改时间
*/
@Version
@JSONField(format="yyyy-MM-dd HH:mm:ss")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

/**
* 测试用用户
*
* @author Amane64
*/
@EqualsAndHashCode(callSuper = true)
@TableName(value = "user")
@Data
public class User extends FullTimeEntity {
/**
* 账号
*/
private String account;

/**
* 密码
*/
private String password;

/**
* 上次登录时间
*/
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastLoginTime;
}

/**
* 角色
*
* @author Amane64
*/
@EqualsAndHashCode(callSuper = true)
@TableName(value = "role")
@Data
public class Role extends FullTimeEntity {
/**
* 角色名
*/
private String name;

/**
* 角色字段
*/
private String value;
}

/**
* 用户角色链接键
*
* @author Amane64
*/
@EqualsAndHashCode(callSuper = true)
@TableName(value = "lk_user_role")
@Data
public class LkUserRole extends BaseEntity implements LkEntity {

/**
* 用户主键
*/
@LkField(position = "A")
private Long userId;

/**
* 角色主键
*/
@LkField(position = "B")
private Long roleId;

@Override
public Long getAId() {
return getUserId();
}

@Override
public void setAId(Long aId) {
setUserId(aId);
}

@Override
public Long getBId() {
return getRoleId();
}

@Override
public void setBId(Long bId) {
setRoleId(bId);
}
}

/**
* 用户权限
*
* @author Amane64
*/
@EqualsAndHashCode(callSuper = true)
@TableName(value ="permission")
@Data
public class Permission extends BaseEntity {
/**
* 角色主键
*/
private Long roleId;

/**
* 权限字段
*/
private String value;
}

权限常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 权限常量
*
* @author Amane64
*/
public class PermissionContext {
// 用户部分
/**
* 用户获取个人信息
*/
public static final String USER_INFO = "user:info";
/**
* 用户修改个人信息
*/
public static final String USER_UPDATE_SELF = "user:updateSelf";
/**
* 查询其它用户
*/
public static final String USER_QUERY_OTHER = "user:queryOther";
/**
* 查询其他用户敏感信息
*/
public static final String USER_QUERY_OTHER_SENSITIVE = "user:queryOtherSensitive";
/**
* 操作其他用户
*/
public static final String USER_OPERATE_OTHER = "user:operateOther";
/**
* 增删改用户
*/
public static final String USER_CRUD = "user:crud";

/**
* 获取所有权限值列表
*
* @return 权限值列表
*/
public static List<String> getAll() throws IllegalAccessException {
var constants = new ArrayList<String>();
Field[] fields = PermissionContext.class.getDeclaredFields();
for (Field field : fields)
if (java.lang.reflect.Modifier.isStatic(field.getModifiers())
&& java.lang.reflect.Modifier.isFinal(field.getModifiers()))
constants.add((String) field.get(null));
return constants;
}
}

系统默认用户常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 系统默认用户常量
*
* @author Amane64
*/
public class CommonUserContext {
/**
* 管理员,拥有所有权限
*/
public static final String ADMIN = "admin";
/**
* 管理员主键
*/
public static final Long ADMIN_ID = 1L;
/**
* 基础用户
*/
public static final String USER = "user";
/**
* 基础用户主键
*/
public static final Long USER_ID = 2L;
}

二、注册 Sa-Token 拦截器

为了使用注解鉴权和路由鉴权,我们需要在Web层配置类注册 Sa-Token 拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 注册 web 层相关组件
*
* @author Amane64
*/
@Slf4j
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {

/**
* 注册 Sa-Token 拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
log.info("注册 Sa-Token 拦截器");
registry.addInterceptor(
// 绑定 checkLogin 方法,对路由进行登录验证
new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
// 放行登录路由接口
.excludePathPatterns("/login");
}

}

三、重写StpInterface接口

因为每个项目的需求不同,其权限设计也千变万化,因此 [ 获取当前账号权限码集合 ] 这一操作不可能内置到框架中, 所以 Sa-Token 将此操作以接口的方式暴露出来,以方便根据实际业务逻辑进行重写。

按照我们的逻辑模型来说,应将RoleService继承StpInterface,并在实现类重写对应方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* 角色服务接口,继承 StpInterface
*
* @author Amane64
*/
public interface RoleService extends IService<Role>, StpInterface {
}

@Service
@RequiredArgsConstructor
public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService {
private final LkUserRoleService lkUserRoleService;
private final PermissionService permissionService;

/**
* 获取权限码列表
*
* @param loginId 账号 id,即你在调用 StpUtil.login(id) 时写入的标识值
* @param loginType 账号体系标识,多账户认证使用
* @return 权限码列表
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 查询角色列表
List<Role> roleList = lkUserRoleService.getBList(Long.parseLong((String) loginId));

// 查询权限码列表
var permissionValueSet = new HashSet<String>();
for (Role role : roleList) {
// 若为管理员角色,直接获取所有权限码
if (role.getValue().equals(CommonUserContext.ADMIN))
try {
permissionValueSet.addAll(PermissionContext.getAll());
break;
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}

// 注入权限码
List<Permission> singlePermissionList = permissionService.getByRoleId(role.getId());
singlePermissionList.forEach(permission -> permissionValueSet.add(permission.getValue()));
}

// 封装并返回
return permissionValueSet.stream().toList();
}

/**
* 获取用户列表
*
* @param loginId 账号 id,即你在调用 StpUtil.login(id) 时写入的标识值
* @param loginType 账号体系标识,多账户认证使用
* @return 用户列表
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 查询角色列表
Long userId = Long.parseLong((String) loginId);
List<Role> roleList = lkUserRoleService.getBList(userId);

// 解析、封装、返回
var resList = new ArrayList<String>();
for (Role role : roleList) {
// 若为管理员角色,直接返回管理员角色这一个,并整理数据库
if (role.getValue().equals(CommonUserContext.ADMIN)) {
resList.clear();
resList.add(CommonUserContext.ADMIN);
var idList = new ArrayList<Long>();
idList.add(CommonUserContext.ADMIN_ID);
lkUserRoleService.updateALinks(userId, idList);
break;
}

// 封装角色
resList.add(role.getValue());
}
return resList;
}
}

PermissionService中的getByRoleId(Long roleId)实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 权限服务接口
*
* @author Amane64
*/
public interface PermissionService extends IService<Permission> {
/**
* 根据角色ID获取权限列表
*
* @param roleId 角色ID
* @return 权限列表
*/
List<Permission> getByRoleId(Long roleId);
}

@Service
public class PermissionServiceImpl extends ServiceImpl<PermissionMapper, Permission> implements PermissionService {
@Override
public List<Permission> getByRoleId(Long roleId) {
return this.list(new LambdaQueryWrapper<Permission>()
.eq(Permission::getRoleId, roleId));
}
}

四、拦截鉴权失败异常并自定义返回

权限错误的异常分别为NotPermissionExceptionNotRoleException,拦截这两个异常即可。

[!TIP]

全局异常配置参考:Spring Boot 全局异常拦截配置

1
2
3
4
5
6
7
8
9
10
11
12
// 均返回 http 403 状态码
@ExceptionHandler(NotPermissionException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<?> notPermissionException(NotPermissionException e) {
// 具体实现略...
}

@ExceptionHandler(NotRoleException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<?> notRoleException(NotRoleException e) {
// 具体实现略...
}

五、配置权限并测试

准备用户相关接口,均作简略验证,不实现具体逻辑。

验证结果略,读者可自行认证(主要是太多了,偷点懒)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 用户相关控制器
*
* @author Amane64
*/
@RestController
@RequestMapping("/user")
public class UserController {

@SaCheckPermission(PermissionContext.USER_INFO)
@GetMapping("/info")
public void info() {
}

@SaCheckPermission(PermissionContext.USER_CRUD)
@PatchMapping("/add")
public void add() {
}

@SaCheckRole(CommonUserContext.ADMIN)
@GetMapping("/list")
public void list() {
}
}