【鉴权架构】SpringBoot + Sa-Token + MyBatis + MySQL + Redis 实现用户鉴权、角色管理、权限管理
sa-token 官方文档
基础登录功能
- 引入依赖
由于使用SpringBoot3,故使用较新版
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId><version>1.44.0</version></dependency>
- yml配置
前端小程序使用,故禁用session,使用token模式
# 用户鉴权
sa-token:# token 名称# Authorization: Bearer token值token-name: Authorizationtoken-prefix: Bearer # 设置Token前缀 【注意有空格】is-read-header: true # 从Header读取Tokenis-read-cookie: false # 禁用Cookie(纯Token模式)# token 有效期(单位:秒) 默认30天,-1 代表永久有效timeout: 2592000# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结active-timeout: -1# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)is-concurrent: true# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)is-share: false# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)token-style: uuid# 是否输出操作日志is-log: true
- 全局拦截器 拦截未登录用户
新建全局拦截器 实现 WebMvcConfigurer 接口
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 注册Sa-Token拦截器registry.addInterceptor(new SaInterceptor(handler -> {SaRouter.match("/**").notMatch(// 用户认证"/user/login","/user/register",// Swagger/Knife4j 接口文档"/doc.html","/webjars/**","/swagger-resources/**","/v3/api-docs","/v3/api-docs/**","/favicon.ico",// 其他需要放行的路径"/error").check(StpUtil::checkLogin); // 校验是否登录 (调试阶段可放开)})).addPathPatterns("/**");}
}
- 在用户登录服务中使用 StpUtil 服务进行登录
satoken 会在内存中保存 id 和 token,标记用户登录
StpUtil.login(user.getId()); // 生成Token并保存
@Overridepublic LoginUserVO userLogin(String userAccount, String userPassword) {// 1. 校验if (StrUtil.hasBlank(userAccount, userPassword)) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");}if (userAccount.length() < 4) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号错误");}if (userPassword.length() < 8) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");}// 2. 加密String encryptPassword = getEncryptPassword(userPassword);// 查询用户是否存在QueryWrapper queryWrapper = new QueryWrapper();queryWrapper.eq("userAccount", userAccount);queryWrapper.eq("userPassword", encryptPassword);User user = this.mapper.selectOneByQuery(queryWrapper);// 用户不存在if (user == null) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");}// 3. SaToken登录StpUtil.login(user.getId()); // 生成Token并保存// 4. 返回信息并携带 tokenLoginUserVO loginUserVO = this.getLoginUserVO(user);loginUserVO.setToken(StpUtil.getTokenValue());return loginUserVO;}
引入Redis
由于satoken默认将数据存储与内存中,每次重启都需要重新登录,即不方便
故引入redis进行持久化存储
- 引入Redis
<!-- Redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
- 引入 Satoken 的redis相关依赖
<!-- Sa-Token 整合 Redis --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-redis-jackson</artifactId><version>1.44.0</version></dependency><!-- 提供Redis连接池 --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency>
- yml配置redis
注:spring3 的redis配置在 spring/data 下
srping:# Redis 配置data:redis:host: localhostport: 6379password:
- 启动redis
redis-server
无需修改代码,可以看到登录后 satoken会自动将数据存储在redis中
SaToken基础注解鉴权
- sa-token 接口设置权限就是使用 注解
@SaCheckLogin // 登录才能访问
@SaCheckPermission("user:create") // 权限标识鉴权
@SaCheckRole("super-admin") // 角色标识鉴权// 注解式鉴权:只要具有其中一个权限即可通过校验
@SaCheckPermission(value = {"user-add", "user-all", "user-delete"}, mode = SaMode.OR)
@ApiOperationSupport(order = 4)@SaCheckLogin // 登录才能访问@Operation(summary = "获取当前登录用户信息")@GetMapping("/get/login")public BaseResponse<LoginUserVO> getLoginUser() {User loginUser = userService.getLoginUser();return ResultUtils.success(userService.getLoginUserVO(loginUser));}
- 实现 StpInterface 接口,在用户登录时 获取其权限并注入
重写 getPermissionList 和 getRoleList 方法,在用户登录时
sa-token 会调用这两个方法,注入用户角色和权限
我这里读取数据库,可以写死数据进行模拟
@Component
public class StpInterfaceConfig implements StpInterface {@Resourceprivate UserService userService;@Resourceprivate RoleService roleService;/*** 一个账号拥有的权限码 集合** @return*/@Overridepublic List<String> getPermissionList(Object loginId, String loginType) {Long userId = Long.parseLong(loginId.toString());String roleKey = userService.getRoleKeyByUserId(userId);return roleService.getPermKeyListByRoleKey(roleKey);}/*** 一个账号拥有的角色** @return*/@Overridepublic List<String> getRoleList(Object loginId, String loginType) {Long userId = Long.parseLong(loginId.toString());return List.of(userService.getRoleKeyByUserId(userId));}
}
- 只要注入的角色/权限与 注解对应,即可访问相应接口
Sa-Token + Mysql 鉴权
这部分是数据库相关内容,之前sa-token相关配置已经完成
这部分主要是sql设计、相关服务、接口
数据库设计
设计 用户表、角色表、权限表、港口表(部门)
-- 用户表
CREATE TABLE IF NOT EXISTS user (id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,user_account VARCHAR(256) NOT NULL COMMENT '账号',user_password VARCHAR(512) NOT NULL COMMENT '密码',user_name VARCHAR(256) NULL COMMENT '用户昵称',user_avatar VARCHAR(1024) NULL COMMENT '用户头像',user_profile VARCHAR(512) NULL COMMENT '用户简介',
# department_id BIGINT NULL COMMENT '部门id',role_key VARCHAR(256) NOT NULL COMMENT '绑定的角色标识',edit_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '编辑时间',create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',UNIQUE KEY uk_user_account (user_account),INDEX idx_user_name (user_name),INDEX idx_role_key (role_key)
) COMMENT '用户表' COLLATE = utf8mb4_unicode_ci;-- 港口表
CREATE TABLE IF NOT EXISTS port (id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,port_name VARCHAR(256) NOT NULL COMMENT '港口名称',port_type TINYINT NOT NULL COMMENT '港口类型: 0=普通港口 1=平台',create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',UNIQUE KEY uk_port_name (port_name)
) COMMENT '港口表' COLLATE = utf8mb4_unicode_ci;-- 角色表
CREATE TABLE IF NOT EXISTS sa_role (id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,role_key VARCHAR(256) NOT NULL COMMENT '角色标识(唯一,不可修改)',role_name VARCHAR(256) NOT NULL COMMENT '角色名称',role_level INT NOT NULL COMMENT '角色等级(越小权限越大)',perm_key_list TEXT COMMENT '权限key列表(JSON数组格式)',port_id BIGINT NOT NULL COMMENT '绑定的港口id',status TINYINT DEFAULT 1 NOT NULL COMMENT '状态:1启用 0停用',create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',UNIQUE KEY uk_role_key (role_key),INDEX idx_port_id (port_id)
) COMMENT '角色表' COLLATE = utf8mb4_unicode_ci;-- 权限表 (菜单/接口二合一)
CREATE TABLE IF NOT EXISTS sa_permission (id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,parent_id BIGINT DEFAULT 0 NOT NULL COMMENT '父ID (0=根节点)',perm_key VARCHAR(256) NOT NULL COMMENT '权限标识(唯一,接口权限/菜单路由)',perm_name VARCHAR(256) NOT NULL COMMENT '权限名称',type TINYINT NOT NULL COMMENT '类型:0目录 1菜单 2接口',request_path VARCHAR(512) NULL COMMENT '请求路径(接口用) 或 前端路由',icon VARCHAR(128) NULL COMMENT '前端图标',sort INT DEFAULT 0 NOT NULL COMMENT '排序字段',create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',UNIQUE KEY uk_perm_key (perm_key),INDEX idx_parent_id (parent_id),INDEX idx_type (type)
) COMMENT '权限表' COLLATE = utf8mb4_unicode_ci;
- 用户表只绑定角色表 (role_key 属性对应 角色表的角色标识)
一个用户只能拥有一个角色,方便管理 n :1
- 角色表绑定 港口表 (port_id属性对应港口表id) n :1
- 角色表绑定 权限表列表 (perm_key_list 属性存储 perm_key权限标识数组)
不使用 角色-权限表 的原因是存储为json数组,方便存取,不用大规模读取表
"permKeyList": ["sys","user","user:delete"]
- 权限表 存储两种类型:接口 和 菜单
接口权限 用于satoken接口级鉴权
菜单权限 用于前端路由使用,都支持前端管理员分配权限
- 港口表(部门表) 分为平台和港区
平台管理员可以管理全平台
港区管理员只能管理港区内相关人员、内容
业务开发
逻辑删除与唯一索引冲突
MyBatis 设置 is_delete 逻辑删除可能与 表的唯一依赖出现冲突
冲突的产生:
● 假设你有一个 user 表,其中 username 字段有唯一索引。
● 现有用户 JohnDoe(username = ‘johndoe’, is_deleted = 0)。
● 当你“删除”这个用户后,这条记录变为 username = ‘johndoe’, is_deleted = 1。
● 现在,如果你想重新创建一个用户名为 johndoe 的新用户并执行插入操作,数据库会尝试插入 (‘johndoe’, 0)。
● 然而,唯一索引检查时会发现表中已经存在一条 (‘johndoe’, 1) 的记录。由于唯一索引并不感知 is_deleted 字段的业务逻辑,它只检查 username 的值,因此 ‘johndoe’ 已经存在,插入操作就会违反唯一索引约束,导致报错。
由于我的代码中每个表设置了唯一索引,故每个表 有数据删除后都会 出现唯一索引冲突
原始代码:
- 数据库设计
如下表设计:port_name 字段设置了唯一索引
-- 港口表
CREATE TABLE IF NOT EXISTS port (id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,port_name VARCHAR(256) NOT NULL COMMENT '港口名称',port_type TINYINT NOT NULL COMMENT '港口类型: 0=普通港口 1=平台',create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',UNIQUE KEY uk_port_name (port_name)
) COMMENT '港口表' COLLATE = utf8mb4_unicode_ci;
- MyBatis 设置逻辑删除
原来:直接使用 removeById() MyBatis会自动调用将逻辑删除字段设置为 1
@TableLogicprivate Integer isDelete;// yml配置
mybatis-plus:configuration:map-underscore-to-camel-case: falselog-impl: org.apache.ibatis.logging.stdout.StdOutImplglobal-config:db-config:logic-delete-field: isDelete # 全局逻辑删除的实体字段名logic-delete-value: 1 # 逻辑已删除值(默认为 1)logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
// 无需全局配置
// MyBatis Felx 设置逻辑删除 默认 0 1 @Column(value = "is_delete", isLogicDelete = true)private Integer isDelete;
解决方案
方案一:
若使用MyBatis-plus 可以直接配置避免冲突,比较高效解决方案
- 原来的删除值为 1,可以将删除值设置为 null 空值
- 修改 is_delete 字段,删去 not null, 允许为空
方案二:
由于我使用的是 MyBatis Flex,参考官方文档,其值的设置不能直接配置
需要通过实现 FlexGlobalConfig globalConfig = FlexGlobalConfig.getDefaultConfig(); 来进行配置
尝试使用该类后发现 不能传入空值,也就是不支持将删除后的值设置为空
MyBatisFlex 不支持配置空值,业务开发大半不可能更换框架,只能另辟蹊径
若MySQL版本在** 8.0.13+ **,可以修改表结构使用函数索引
函数索引 (IF(is_delete = 0, 0, NULL)) 的工作原理:
- 当 is_delete = 0(未删除)时,索引值为 0
- 当 is_delete = 1(已删除)时,索引值为 NULL
- 在 MySQL 中,唯一索引允许有多个 NULL 值,但不允许多个相同的非 NULL 值
- 因此,这个索引确保了:
○ 未删除的记录中,user_account 必须是唯一的(因为索引值为 (user_account, 0))
○ 已删除的记录可以有多个相同的 user_account(因为索引值为 (user_account, NULL),而多个 NULL 值不违反唯一约束)
-- 港口表
CREATE TABLE IF NOT EXISTS port (id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,port_name VARCHAR(256) NOT NULL COMMENT '港口名称',port_type TINYINT NOT NULL COMMENT '港口类型: 0=普通港口 1=平台',create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',-- 使用函数索引替代普通唯一索引UNIQUE INDEX uk_port_name_is_delete (port_name, (IF(is_delete = 0, 0, NULL)))
) COMMENT '港口表' COLLATE = utf8mb4_unicode_ci;
如此,也能有效解决 唯一索引冲突问题!