目前在国内的后端开发中,常用的安全框架有spring security、shiro。现在,介绍一款由国人开发的安全框架Sa-Token。这个框架完全由国人开发,所提供的Api文档和一些设置都是比较符合国人的开发习惯的,本次就来介绍一下如何在spring boot框架中整合Sa-Token框架,以实现我们最常使用的登录认证和权限校验;
Sa-Token 是由国人开发的 一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、分布式Session会话、微服务网关鉴权 等一系列权限相关问题。
版本:spring boot3.15、Sa-Token1.37.0
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.32</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.18</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.3.0</version>
</dependency>
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.37.0</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
1、用户表
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
email VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
2、角色表
CREATE TABLE roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
3、权限表
CREATE TABLE permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
4、用户角色表
CREATE TABLE user_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (role_id) REFERENCES roles(id)
);
5、角色权限表
CREATE TABLE role_permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES roles(id),
FOREIGN KEY (permission_id) REFERENCES permissions(id)
);
与spring security不同的是,引入了Sa-Token的依赖之后。并不会自动生效,需要我们自己编写相应的逻辑代码;
写一个TestController,用来进行测试;
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/hello")
public String hello() {
System.out.println("说hello啊");
return "test接口的hello";
}
}
我们现在启动项目,并访问/test/hello接口,发现能够正常访问。这一点与spring security是不同的,spring security默认会拦截所有请求;
Sa-Token已经为我们编写了很多我们经常使用的功能,我们只需要在yml配置文件中进行配置即可。以下是一些常用的配置:
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: satoken
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
is-share: true
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: true
使用Sa-Token进行登录认证是非常简单的,只需要一句代码就可以搞定:
// 会话登录:参数填写要登录的账号id,建议的数据类型:long | int | String, 不可以传入复杂类型,如:User、Admin 等等
StpUtil.login(Object id);
只此一句代码,便可以使会话登录成功,实际上,Sa-Token 在背后做了大量的工作,包括但不限于:
我们暂时不需要完整了解整个登录过程,只需要记住关键一点:Sa-Token 为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端
。
一个登录接口:
@Autowired
IUsersService usersService;
@PostMapping("/login")
public SaResult login(String username, String password){
//根据用户名从数据库中查询
Users users = usersService.getOne(new LambdaQueryWrapper<Users>().eq(Users::getUsername, username));
if(users == null){
return SaResult.error("用户名不存在");
}
if(!users.getPassword().equals(password)){
return SaResult.error("密码错误");
}
//根据用户id登录
StpUtil.login(users.getId());
return SaResult.ok("登录成功");
}
运行检验:
发现确实能够登录成功。
此处仅仅做了会话登录,但并没有主动向前端返回 token 信息。 是因为不需要吗?严格来讲是需要的,只不过 StpUtil.login(id)
方法利用了 Cookie 自动注入的特性,省略了你手写返回 token 的代码。
如果你对 Cookie 功能还不太了解,也不用担心,我们会在之后的 [ 前后端分离 ] 章节中详细的阐述 Cookie 功能,现在你只需要了解最基本的两点:
因此,在 Cookie 功能的加持下,我们可以仅靠 StpUtil.login(id)
一句代码就完成登录认证。
除了登录方法,我们还需要:
// 当前会话注销登录
StpUtil.logout();
// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();
// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();
异常 NotLoginException
代表当前会话暂未登录,可能的原因有很多: 前端没有提交 token、前端提交的 token 是无效的、前端提交的 token 已经过期 …… 等等
还有一些登录过程中常用的方法:
// 获取当前会话账号id, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.getLoginId();
// 类似查询API还有:
StpUtil.getLoginIdAsString(); // 获取当前会话账号id, 并转化为`String`类型
StpUtil.getLoginIdAsInt(); // 获取当前会话账号id, 并转化为`int`类型
StpUtil.getLoginIdAsLong(); // 获取当前会话账号id, 并转化为`long`类型
// ---------- 指定未登录情形下返回的默认值 ----------
// 获取当前会话账号id, 如果未登录,则返回 null
StpUtil.getLoginIdDefaultNull();
// 获取当前会话账号id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
StpUtil.getLoginId(T defaultValue);
// 获取当前会话的 token 值
StpUtil.getTokenValue();
// 获取当前`StpLogic`的 token 名称
StpUtil.getTokenName();
// 获取指定 token 对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);
// 获取当前会话剩余有效期(单位:s,返回-1代表永久有效)
StpUtil.getTokenTimeout();
// 获取当前会话的 token 信息参数
StpUtil.getTokenInfo();
无 Cookie 模式:特指不支持 Cookie 功能的终端,通俗来讲就是我们常说的 —— 前后端分离模式。
常规 Web 端鉴权方法,一般由 Cookie模式
完成,而 Cookie 有两个特性:
这就使得我们在前端代码中,无需任何特殊操作,就能完成鉴权的全部流程(因为整个流程都是后端控制完成的)
而在app、小程序等前后端分离场景中,一般是没有 Cookie 这一功能的,此时大多数人都会一脸懵逼,咋进行鉴权啊?
见招拆招,其实答案很简单:
修改我们之前写的登录接口,返回token给前端;
@PostMapping("/login")
public SaResult login(String username, String password){
//根据用户名从数据库中查询
Users users = usersService.getOne(new LambdaQueryWrapper<Users>().eq(Users::getUsername, username));
if(users == null){
return SaResult.error("用户名不存在");
}
if(!users.getPassword().equals(password)){
return SaResult.error("密码错误");
}
//根据用户id登录,第1步,先登录上
StpUtil.login(users.getId());
// 第2步,获取 Token 相关参数
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
// 第3步,返回给前端
return SaResult.data(tokenInfo);
}
在进行一次登录:
可以看到登录成功之后,返回了很多相关的数据,而我们的token也在其中,那么前端就可以将token取出来放到一个状态管理器(pinia中),并且在每次请求时都将token放在请求头中。
如果你对 Cookie 非常了解,那你就会明白,所谓 Cookie ,本质上就是一个特殊的header
参数而已, 而既然它只是一个 header 参数,我们就能手动模拟实现它,从而完成鉴权操作。
假设我们有如下需求:
我们怎么实现呢?给每个接口加上鉴权注解?手写全局拦截器?似乎都不是非常方便。
在这个需求中我们真正需要的是一种基于路由拦截的鉴权模式,那么在Sa-Token怎么实现路由拦截鉴权呢?
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
// 注册拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns("/users/login");
}
}
以上代码,我们注册了一个基于 StpUtil.checkLogin()
的登录校验拦截器,并且排除了/user/login
接口用来开放登录(除了/user/login
以外的所有接口都需要登录才能访问)这就与我们spring security有了一点点相似了,因为spring security默认的就是所有的接口都被拦截到。
我们再启动项目,并访问我们最开始写的/test/hello接口;
可以看到这个接口被拦截了。那么我们带上之前登录成功,生成的token之后再去访问呢?
可以看到我们成功访问了这个接口;
new SaInterceptor(handle -> StpUtil.checkLogin())
是最简单的写法,代表只进行登录校验功能。实际上Sa-Token还可以实现一些更详细的校验规则,在Sa-Token的官网上可以看到。
Sa-Token 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:
所以,sa-token为我们提供了持久化的方法,我们可以在登录成功之后将token数据存储到redis中。然后,每次请求时到redis中取到我们的token数据。这样就实现了即使重启服务,我们之前的登录token也不会丢失
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.37.0</version>
</dependency>
sa-token已经帮我们整合了redis,我们自需要引入相应的pom依赖。其他的上层Api都不变。
我们已经了解了常用的登录认证方法,接下来,我们来看一下权限校验;
1、获取当前用户的权限码集合:
因为每个项目的需求不同,其权限设计也千变万化,因此 [ 获取当前账号权限码集合 ] 这一操作不可能内置到框架中, 所以 Sa-Token 将此操作以接口的方式暴露给你,以方便你根据自己的业务逻辑进行重写。
你需要做的就是新建一个类,实现 StpInterface
接口
/**
* 自定义权限加载接口实现类
*/
@Component // 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展
public class StpInterfaceImpl implements StpInterface {
/**
* 返回一个账号所拥有的权限码集合
*/
// 角色权限表
@Autowired
IRolePermissionsService rolePermissionsService;
// 用户角色表
@Autowired
IUserRolesService userRolesService;
//权限表
@Autowired
IPermissionsService permissionsService;
// 角色表
@Autowired
IRolesService rolesService;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 根据用户id从用户角色表中获取角色id
List<UserRoles> roleIds = userRolesService.list(new LambdaQueryWrapper<UserRoles>()
.eq(UserRoles::getUserId,Integer.parseInt(loginId.toString())));
List<Integer> rolesList = roleIds.stream().map(UserRoles::getRoleId).toList();
if (!(rolesList.size() >0)){
// 没有任何权限
return null;
}
List<String> list = new ArrayList<>();
rolesList.forEach(roleId ->{
// 根据角色id从角色权限表中获取权限id
List<RolePermissions> rolePermissions = rolePermissionsService.list(new LambdaQueryWrapper<RolePermissions>().
eq(RolePermissions::getRoleId, roleId));
// 根据权限id从权限表中获取权限名称
rolePermissions.forEach(permissionsId->{
Permissions permissions = permissionsService.getById(permissionsId.getPermissionId());
list.add(permissions.getName());
});
});
// 返回用户所有权限,
return list;
}
/**
* 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
// 根据用户id从用户角色表中获取角色id
List<UserRoles> roleIds = userRolesService.list(new LambdaQueryWrapper<UserRoles>()
.eq(UserRoles::getUserId,Integer.parseInt(loginId.toString())));
List<String> list = new ArrayList<>();
if (!(roleIds.size() >0)){
// 用户没有分配角色
return null;
}
roleIds.forEach(roleId ->{
Roles byId = rolesService.getById(roleId.getRoleId());
list.add(byId.getName());
});
// 返回用户所有角色标识集合
return list;
}
}
我所实现的是标准的RBAC(基于用户、角色、权限的访问控制模型)。所以,在得到用户id的情况下、先根据用户角色表查出角色id、在根据角色权限表查询权限id,在根据权限表查出具体权限名称。
上面使用了Mybatis-plus的条件构造器和stream流的形式进行查询。
参数解释:
StpUtil.login(id)
时写入的标识值。有同学会产生疑问:我实现了此接口,但是程序启动时好像并没有执行,是不是我写错了? 答:不执行是正常现象,程序启动时不会执行这个接口的方法,在每次调用鉴权代码时,才会执行到此。
权限校验:
然后就可以用以下 api 来鉴权了:
// 获取:当前账号所拥有的权限集合
StpUtil.getPermissionList();
// 判断:当前账号是否含有指定权限, 返回 true 或 false
StpUtil.hasPermission("user.add");
// 校验:当前账号是否含有指定权限, 如果验证未通过,则抛出异常: NotPermissionException
StpUtil.checkPermission("user.add");
// 校验:当前账号是否含有指定权限 [指定多个,必须全部验证通过]
StpUtil.checkPermissionAnd("user.add", "user.delete", "user.get");
// 校验:当前账号是否含有指定权限 [指定多个,只要其一验证通过即可]
StpUtil.checkPermissionOr("user.add", "user.delete", "user.get");
扩展:NotPermissionException
对象可通过 getLoginType()
方法获取具体是哪个 StpLogic
抛出的异常
角色校验:
在 Sa-Token 中,角色和权限可以分开独立验证
// 获取:当前账号所拥有的角色集合
StpUtil.getRoleList();
// 判断:当前账号是否拥有指定角色, 返回 true 或 false
StpUtil.hasRole("super-admin");
// 校验:当前账号是否含有指定角色标识, 如果验证未通过,则抛出异常: NotRoleException
StpUtil.checkRole("super-admin");
// 校验:当前账号是否含有指定角色标识 [指定多个,必须全部验证通过]
StpUtil.checkRoleAnd("super-admin", "shop-admin");
// 校验:当前账号是否含有指定角色标识 [指定多个,只要其一验证通过即可]
StpUtil.checkRoleOr("super-admin", "shop-admin");
扩展:NotRoleException
对象可通过 getLoginType()
方法获取具体是哪个 StpLogic
抛出的异常
权限通配符:
Sa-Token允许你根据通配符指定泛权限,例如当一个账号拥有art.*
的权限时,art.add
、art.delete
、art.update
都将匹配通过
// 当拥有 art.* 权限时
StpUtil.hasPermission("art.add"); // true
StpUtil.hasPermission("art.update"); // true
StpUtil.hasPermission("goods.add"); // false
// 当拥有 *.delete 权限时
StpUtil.hasPermission("art.delete"); // true
StpUtil.hasPermission("user.delete"); // true
StpUtil.hasPermission("user.update"); // false
// 当拥有 *.js 权限时
StpUtil.hasPermission("index.js"); // true
StpUtil.hasPermission("index.css"); // false
StpUtil.hasPermission("index.html"); // false
上帝权限:当一个账号拥有 "*"
权限时,他可以验证通过任何权限码 (角色认证同理)
在我们的项目的TestController中进行测试:
@GetMapping("/hello")
public String hello() {
System.out.println("验证权限,是否具有‘所有权限’===============>"+StpUtil.hasPermission("所有权限"));
System.out.println("验证角色,是否具有‘管理员’角色===============>"+StpUtil.hasRole("管理员"));
System.out.println("说hello啊");
return "test接口的hello";
}
我们测试的最终结果:
可以看到确实输出了两个true。并且,只有在每次调用鉴权代码时,才会执行到我们自定义的权限方法中。
我本来是想写一篇介绍spring boot项目中整合Sa-Token来实现最常用的登录校验和权限认证的,但是写着写着就变成官网的复制机了。我在本篇文章中大量复制了官网上的内容,原本只是想复制一些官方介绍就行了。但是这也从侧面说明了Sa-Token官网制作的确实是比较好的,基本上不需要额外的学习,只要你有做过登录和权限方面的项目经验,再看一遍官网的介绍就能直接上手了。
我之前写过一个B2C模式的购物商台,分为用户端和管理端。管理端的登录和权限校验是用spring security写的。现在又了解了Sa-Token之后,真的还是认为Sa-Token的使用是比较简单的。(也可能是我的项目还是比较简单的,并没有多少权限要实现)并且这也是我们国人写的开源框架,在一些方法的封装、Api的设计上还是能直接理解的。在这里也算是为Sa-Token这个框架打个广告吧,希望大家还是能够多多支持国产,国内的开源之路也是充满了坎坷与艰辛。