Sa-Token
文章目录
https://sa-token.cc/
文档地址 -> https://sa-token.cc/doc.html#/
一:认证流程
1:数据库表设计
基于RBAC的角色权限表控制,一般在认证授权会涉及到如下的五张表
user表 -> 有哪些用户,用户的基本信息
user_id | username | password | emp_name | dept_name |
---|---|---|---|---|
1 | zhangsan | zhangsan | 张三 | 运营部门 |
2 | lisi | lisi | 李四 | 设计部门 |
role表 -> 当前的系统有哪些角色
role_id | role_code | role_name |
---|---|---|
1 | admin | 管理员 |
2 | guest | 客户 |
promission表 -> 当前的系统有那些权限
promission_id | promission_code |
---|---|
1 | user_read |
2 | user_wirte |
3 | product_read |
4 | priduct_wirte |
role-user表 -> 角色用户关联表,每一个用户有那些角色在这个表维护
ru_id | role_id | user_id |
---|---|---|
1 | 1 | 1 |
2 | 2 | 1 |
3 | 2 | 2 |
role-promission表 -> 角色权限关联表,每一种角色有那些权限在这个表维护
rp_id | role_id | promisssion_id |
---|---|---|
1 | 1 | 1 |
2 | 1 | 2 |
3 | 1 | 3 |
4 | 1 | 4 |
5 | 2 | 1 |
6 | 2 | 3 |
2:使用Sa-Token完成认证
1:依赖的引入和配置的引入
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.41.0</version>
</dependency>
server:
# 端口
port: 8081
############## 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: false
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: true
2:配置文件编写,将sa-token加入到web拦截器中,保证所有的接口都受到sa-token拦截器拦截
package com.cui.satokendemo.config;
import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* SaToken配置 -> 鉴权拦截器
* @author cui haida
* 2025/3/29
*/
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 的拦截器,打开注解式鉴权功能
registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
}
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
.addInclude("/**")
.addExclude("/favicon.ico")
.addExclude("/satoken/getTokenInfo")
.addExclude("/satoken/login")
.addExclude("/satoken/logout")
.setAuth(obj -> {
// 登录校验 -- 拦截所有路由,并排除/login 用于开放登录
SaRouter.match("/**", "/satoken/login", r-> System.out.println("登录校验!"));
});
}
}
3:认证测试 -> StpUtil
package com.cui.satokendemo.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 登录注销控制器
* @author cui haida
* 2025/3/29
*/
@RestController
@RequestMapping("/login")
public class LoginController {
@RequestMapping("/doLogin")
public SaResult doLogin(String username, String password) {
// 模拟查询数据库,比较用户名密码
if ("admin".equals(username) && "123456".equals(password)) {
// 模拟用户id
String userId = "10001";
// 将当前用户的userid写入session,并返回token
StpUtil.login(userId);
return SaResult.ok("登录成功");
} else {
// 登录失败
return SaResult.error();
}
}
@RequestMapping("/getTokenInfo")
public String getTokenInfo() {
// 获取当前会话的token信息
return StpUtil.getTokenInfo().toString();
}
@RequestMapping("/isLogin")
public String isLogin() {
// 判断当前会话是否登录
return StpUtil.isLogin() ? "已登录" : "未登录";
}
@RequestMapping("/logout")
public String logout() {
// 退出登录
StpUtil.logout();
return "退出成功";
}
}
二:授权流程
1:实现StpInterface接口,获取权限列表和角色列表
package com.cui.satokendemo.impl;
import cn.dev33.satoken.stp.StpInterface;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* @author cui haida
* 2025/3/29
*/
public class StpInterfaceImpl implements StpInterface {
/**
* 获取权限列表
* @param loginId 用户登录的id
* @param loginType 用户登录的类型
* @return 返回一个权限代码的列表,列表中的每一个元素为一个权限代码字符串
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 模拟用户权限
List<String> ans = new ArrayList<>();
// 执行SQL查询,查询条件为用户ID,查询结果为一个包含权限代码的列表。
// SQL语句中使用了三个表的连接查询的所有权限代码。
// List<Map<String, Object>> maps = jdbcTemplate.queryForList("SELECT p.permission_code FROM user u JOIN user_role ur ON u.id = ur.user_id JOIN role r ON ur.role_id = r.id JOIN role_permission rp ON r.id = rp.role_id JOIN permission p ON rp.permission_id = p.id WHERE u.id = ?", loginId);
// 遮历查询结果中的每个元素,提级出权限代码,并添加到权限代码列表中。
// for (Map<String, Object> map : maps) {
// ans.add((String) map.get("permission_code"));
// }
return ans;
}
/**
* 获取角色列表
* @param loginId 用户登录的id
* @param loginType 用户登录的类型
* @return 返回一个角色代码的列表,列表中的每一个元素为一个角色代码字符串
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
List<String> ans = new ArrayList<>();
// 查询出所有的角色代码,并添加到角色代码列表中。
return ans;
}
}
2:使用@SaCheckPermission完成鉴权
package com.cui.satokendemo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
/**
* @author cui haida
* 2025/3/29
*/
@RestController
@RequestMapping("/user")
public class UserController {
@SaCheckPermission("user-read")
@RequestMapping(value = "/findUser", method = RequestMethod.GET)
public SaResult findUser() {
return SaResult.ok();
}
@SaCheckPermission(value = {"user-write", "write"}, mode = SaMode.OR)
@RequestMapping(value = "/addUser", method = RequestMethod.POST)
public SaResult addUser() {
return SaResult.ok();
}
}
三:深入使用
1:集成redis
Sa-Token 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:
- 重启后数据会丢失。
- 无法在分布式环境中共享数据。
为此,Sa-Token 提供了扩展接口,你可以轻松将会话数据存储在一些专业的缓存中间件上(比如 Redis), 做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性。
RedisTemplate 是 SpringBoot 官方推荐的 Redis 客户端,Sa-Token 提供基于 RedisTemplate 的 Redis 整合方案:
<!-- Sa-Token 整合 RedisTemplate -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-template</artifactId>
<version>1.41.0</version>
</dependency>
<!-- 提供 Redis 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
spring:
# redis配置
redis:
# Redis数据库索引(默认为0)
database: 1
# Redis服务器地址
host: 127.0.0.1
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
# password:
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池最大连接数
max-active: 200
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 连接池中的最大空闲连接
max-idle: 10
# 连接池中的最小空闲连接
min-idle: 0
自定义序列化方案
如果你按照上述 RedisTemplate 方案进行集成测试,会发现框架在 Redis 中是以 json 格式存储数据的。
可以自定义数据序列化格式吗?当然是可以的。
框架的默认序列化层调用为 String 序列化
-> JSON 序列化
。要自定义数据序列化方式你可以从这两方面入手:
- 先说较为底层的
JSON 序列化
,如果你引入的是 sa-token-spring-boot-starter 集成包 (含SpringBoot3) ,那么框架将会自动引入 Jackson 框架作为 JSON 序列化方案。
如果你想更换为其它 JSON 解析框架,可以引入相关依赖:
<!-- Sa-Token 整合 Fastjson -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-fastjson</artifactId>
<version>1.41.0</version>
</dependency>
- 或者你想更直接点,不使用 json 序列化方案,也是可以的。你可以直接自定义数据的 String 序列化方案:
// 设置序列化方案: jdk序列化 (base64编码)
@PostConstruct
public void rewriteComponent() {
SaManager.setSaSerializerTemplate(new SaSerializerTemplateForJdkUseBase64());
}
// 设置序列化方案: jdk序列化 (16进制编码)
@PostConstruct
public void rewriteComponent() {
SaManager.setSaSerializerTemplate(new SaSerializerTemplateForJdkUseHex());
}
// 设置序列化方案: jdk序列化 (ISO-8859-1编码)
@PostConstruct
public void rewriteComponent() {
SaManager.setSaSerializerTemplate(new SaSerializerTemplateForJdkUseISO_8859_1());
}
2:前后端分离(无cookie模式)
无 Cookie 模式:特指不支持 Cookie 功能的终端,通俗来讲就是我们常说的 —— 前后端分离模式。
常规 Web 端鉴权方法,一般由 Cookie模式
完成,而 Cookie 有两个特性:
- 可由后端控制写入。
- 每次请求自动提交。
这就使得我们在前端代码中,无需任何特殊操作,就能完成鉴权的全部流程(因为整个流程都是后端控制完成的)
而在app、小程序等前后端分离场景中,一般是没有 Cookie 这一功能的,此时大多数人都会一脸懵逼,咋进行鉴权啊?
见招拆招,其实答案很简单:
- 不能后端控制写入了,就前端自己写入。(难点在后端如何将 Token 传递到前端)
- 每次请求不能自动提交了,那就手动提交。(难点在前端如何将 Token 传递到后端,同时后端将其读取出来)
后端将token返回到前端
-
首先调用
StpUtil.login(id)
进行登录。 -
调用StpUtil.getTokenInfo()
返回当前会话的 token 详细参数。
- 此方法返回一个对象,其有两个关键属性:
tokenName
和tokenValue
(token 的名称和 token 的值)。 - 将此对象传递到前台,让前端人员将这两个值保存到本地。
- 此方法返回一个对象,其有两个关键属性:
// 登录接口
@RequestMapping("doLogin")
public SaResult doLogin() {
// 第1步,先登录上
StpUtil.login(10001);
// 第2步,获取 Token 相关参数
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
// 第3步,返回给前端
return SaResult.data(tokenInfo);
}
前端将token提交到后端
- 无论是app还是小程序,其传递方式都大同小异。
- 那就是,将 token 塞到请求
header
里 ,格式为:{tokenName: tokenValue}
。 - 以经典跨端框架 uni-app 为例:
// 1、首先在登录时,将 tokenValue 存储在本地,例如:
uni.setStorageSync('tokenValue', tokenValue);
// 2、在发起ajax请求的地方,获取这个值,并塞到header里
uni.request({
url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
header: {
"content-type": "application/x-www-form-urlencoded",
"satoken": uni.getStorageSync('tokenValue') // 关键代码, 注意参数名字是 satoken
},
success: (res) => {
console.log(res.data);
}
});
// 1、首先在登录时,将tokenName和tokenValue一起存储在本地,例如:
uni.setStorageSync('tokenName', tokenName);
uni.setStorageSync('tokenValue', tokenValue);
// 2、在发起ajax的地方,获取这两个值, 并组织到head里
var tokenName = uni.getStorageSync('tokenName'); // 从本地缓存读取tokenName值
var tokenValue = uni.getStorageSync('tokenValue'); // 从本地缓存读取tokenValue值
var header = {
"content-type": "application/x-www-form-urlencoded"
};
if (tokenName != undefined && tokenName != '') {
header[tokenName] = tokenValue;
}
// 3、后续在发起请求时将 header 对象塞到请求头部
uni.request({
url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
header: header,
success: (res) => {
console.log(res.data);
}
});
-
只要按照如此方法将
token
值传递到后端,Sa-Token 就能像传统PC端一样自动读取到 token 值,进行鉴权。 -
你可能会有疑问,难道我每个ajax都要写这么一坨?岂不是麻烦死了?
- 你当然不能每个 ajax 都写这么一坨,因为这种重复性代码都是要封装在一个函数里统一调用的。
3:同端互斥登录
如果你经常使用腾讯QQ,就会发现它的登录有如下特点:它可以手机电脑同时在线,但是不能在两个手机上同时登录一个账号。
同端互斥登录,指的就是:像腾讯QQ一样,在同一类型设备上只允许单地点登录,在不同类型设备上允许同时在线。
在 Sa-Token 中如何做到同端互斥登录?
首先在配置文件中,将 isConcurrent
配置为false,然后调用登录等相关接口时声明设备类型即可:
// 指定`账号id`和`设备类型`进行登录
// 调用此方法登录后,同设备的会被顶下线(不同设备不受影响)
// 再次访问系统时会抛出 NotLoginException 异常,场景值=-4
StpUtil.login(10001, "PC");
// 如果第二个参数填写null或不填,代表将这个账号id所有在线端强制注销
// 被踢出者再次访问系统时会抛出 NotLoginException 异常,场景值=-2
// 指定`账号id`和`设备类型`进行强制注销
StpUtil.logout(10001, "PC");
// 返回当前token的登录设备类型
StpUtil.getLoginDevice();
// 获取指定loginId指定设备类型端的tokenValue
StpUtil.getTokenValueByLoginId(10001, "APP");
4:记住我
如图所示,一般网站的登录界面都会有一个 [记住我]
按钮,当你勾选它登录后,即使你关闭浏览器再次打开网站,也依然会处于登录状态,无须重复验证密码:
Sa-Token的登录授权,默认就是[记住我]
模式,为了实现[非记住我]
模式,你需要在登录时如下设置:
// 设置登录账号id为10001,第二个参数指定是否为[记住我],当此值为false后,关闭浏览器后再次打开需要重新登录
StpUtil.login(10001, false);
实现原理
Cookie作为浏览器提供的默认会话跟踪机制,其生命周期有两种形式,分别是:
- 临时Cookie:有效期为本次会话,只要关闭浏览器窗口,Cookie就会消失。
- 持久Cookie:有效期为一个具体的时间,在时间未到期之前,即使用户关闭了浏览器Cookie也不会消失。
利用Cookie的此特性,我们便可以轻松实现 [记住我] 模式:
- 勾选 [记住我] 按钮时:调用
StpUtil.login(10001, true)
,在浏览器写入一个持久Cookie
储存 Token,此时用户即使重启浏览器 Token 依然有效。 - 不勾选 [记住我] 按钮时:调用
StpUtil.login(10001, false)
,在浏览器写入一个临时Cookie
储存 Token,此时用户在重启浏览器后 Token 便会消失,导致会话失效。
前后端分离下的记住我
Cookie虽好,却无法在前后端分离环境下使用,那是不是代表上述方案在APP、小程序等环境中无效?
准确的讲,答案是肯定的,任何基于Cookie的认证方案在前后端分离环境下都会失效(原因在于这些客户端默认没有实现Cookie功能),不过好在,这些客户端一般都提供了替代方案, 唯一遗憾的是,此场景中token的生命周期需要我们在前端手动控制:
以经典跨端框架 uni-app 为例,我们可以使用如下方式达到同样的效果:
// 使用本地存储保存token,达到 [持久Cookie] 的效果
uni.setStorageSync("satoken", "xxxx-xxxx-xxxx-xxxx-xxx");
// 使用globalData保存token,达到 [临时Cookie] 的效果
getApp().globalData.satoken = "xxxx-xxxx-xxxx-xxxx-xxx";
// 使用 localStorage 保存token,达到 [持久Cookie] 的效果
localStorage.setItem("satoken", "xxxx-xxxx-xxxx-xxxx-xxx");
// 使用 sessionStorage 保存token,达到 [临时Cookie] 的效果
sessionStorage.setItem("satoken", "xxxx-xxxx-xxxx-xxxx-xxx");
登录时不仅可以指定是否为[记住我]
模式,还可以指定一个特定的时间作为 Token 有效时长,如下示例:
// 示例1:
// 指定token有效期(单位: 秒),如下所示token七天有效
StpUtil.login(10001, new SaLoginParameter().setTimeout(60 * 60 * 24 * 7));
// ----------------------- 示例2:所有参数
// `SaLoginParameter`为登录参数Model,其有诸多参数决定登录时的各种逻辑,例如:
StpUtil.login(10001, new SaLoginParameter()
.setDevice("PC") // 此次登录的客户端设备类型, 用于[同端互斥登录]时指定此次登录的设备类型
.setIsLastingCookie(true) // 是否为持久Cookie(临时Cookie在浏览器关闭时会自动删除,持久Cookie在重新打开后依然存在)
.setTimeout(60 * 60 * 24 * 7) // 指定此次登录token的有效期, 单位:秒 (如未指定,自动取全局配置的 timeout 值)
.setToken("xxxx-xxxx-xxxx-xxxx") // 预定此次登录的生成的Token
.setIsWriteHeader(false) // 是否在登录后将 Token 写入到响应头
);
5:完整的登录和注销参数
StpUtil.login(10001, new SaLoginParameter()
.setDeviceType("PC") // 此次登录的客户端设备类型, 一般用于完成 [同端互斥登录] 功能
.setDeviceId("xxxxxxxxx") // 此次登录的客户端设备ID, 登录成功后该设备将标记为可信任设备
.setIsLastingCookie(true) // 是否为持久Cookie(临时Cookie在浏览器关闭时会自动删除,持久Cookie在重新打开后依然存在)
.setTimeout(60 * 60 * 24 * 7) // 指定此次登录 token 的有效期, 单位:秒,-1=永久有效
.setActiveTimeout(60 * 60 * 24 * 7) // 指定此次登录 token 的最低活跃频率, 单位:秒,-1=不进行活跃检查
.setIsConcurrent(true) // 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
.setIsShare(false) // 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个token, 为 false 时每次登录新建一个 token)
.setMaxLoginCount(12) // 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置项才有意义)
.setMaxTryTimes(12) // 在每次创建 token 时的最高循环次数,用于保证 token 唯一性(-1=不循环尝试,直接使用)
.setExtra("key", "value") // 记录在 Token 上的扩展参数(只在 jwt 模式下生效)
.setToken("xxxx-xxxx-xxxx-xxxx") // 预定此次登录的生成的Token
.setIsWriteHeader(false) // 是否在登录后将 Token 写入到响应头
.setTerminalExtra("key", "value")// 本次登录挂载到 SaTerminalInfo 的自定义扩展数据
.setReplacedRange(SaReplacedRange.CURR_DEVICE_TYPE) // 顶人下线的范围: CURR_DEVICE_TYPE=当前指定的设备类型端, ALL_DEVICE_TYPE=所有设备类型端
.setOverflowLogoutMode(SaLogoutMode.LOGOUT) // 溢出 maxLoginCount 的客户端,将以何种方式注销下线: LOGOUT=注销下线, KICKOUT=踢人下线, REPLACED=顶人下线
);
// 当前客户端注销
StpUtil.logout(new SaLogoutParameter()
// 注销范围: TOKEN=只注销当前 token 的会话,ACCOUNT=注销当前 token 指向的 loginId 其所有客户端会话
// 此参数只在调用 StpUtil.logout() 时有效
.setRange(SaLogoutRange.TOKEN)
);
// 指定 token 注销
StpUtil.logoutByTokenValue("xxxxxxxxxxxxxxxxxxxxxxx", new SaLogoutParameter()
// 如果 token 已被冻结,是否保留其操作权 (是否允许此 token 调用注销API)(默认 false)
// 此参数只在调用 StpUtil.[logout/kickout/replaced]ByTokenValue("token") 时有效
.setIsKeepFreezeOps(false)
// 是否保留此 token 的 Token-Session 对象(默认 false)
.setIsKeepTokenSession(true)
);
// 指定 loginId 注销
StpUtil.logout(10001, new SaLogoutParameter()
.setDeviceType("PC") // 设置注销的设备类型 (如果不指定,则默认注销所有客户端)
.setIsKeepTokenSession(true) // 是否保留对应 token 的 Token-Session 对象(默认 false)
.setMode(SaLogoutMode.REPLACED) // 设置注销模式:LOGOUT=注销登录、KICKOUT=踢人下线,REPLACED=顶人下线(默认LOGOUT)
);
6:二级认证
在某些敏感操作下,我们需要对已登录的会话进行二次验证。
比如代码托管平台的仓库删除操作,尽管我们已经登录了账号,当我们点击 [删除] 按钮时,还是需要再次输入一遍密码,这么做主要为了两点:
- 保证操作者是当前账号本人。
- 增加操作步骤,防止误删除重要数据。
所以二级认证就是在已登录会话的基础上,进行再次验证,提高会话的安全性。
在Sa-Token
中进行二级认证非常简单,只需要使用以下API:
// 在当前会话 开启二级认证,时间为120秒
StpUtil.openSafe(120);
// 获取:当前会话是否处于二级认证时间内
StpUtil.isSafe();
// 检查当前会话是否已通过二级认证,如未通过则抛出异常
StpUtil.checkSafe();
// 获取当前会话的二级认证剩余有效时间 (单位: 秒, 返回-2代表尚未通过二级认证)
StpUtil.getSafeTime();
// 在当前会话 结束二级认证
StpUtil.closeSafe();
// 删除仓库
@RequestMapping("deleteProject")
public SaResult deleteProject(String projectId) {
// 第1步,先检查当前会话是否已完成二级认证
if(!StpUtil.isSafe()) {
return SaResult.error("仓库删除失败,请完成二级认证后再次访问接口");
}
// 第2步,如果已完成二级认证,则开始执行业务逻辑
// ...
// 第3步,返回结果
return SaResult.ok("仓库删除成功");
}
// 提供密码进行二级认证
@RequestMapping("openSafe")
public SaResult openSafe(String password) {
// 比对密码(此处只是举例,真实项目时可拿其它参数进行校验)
if("123456".equals(password)) {
// 比对成功,为当前会话打开二级认证,有效期为120秒
StpUtil.openSafe(120);
return SaResult.ok("二级认证成功");
}
// 如果密码校验失败,则二级认证也会失败
return SaResult.error("二级认证失败");
}
- 前端调用
deleteProject
接口,尝试删除仓库。 - 后端校验会话尚未完成二级认证,返回:
仓库删除失败,请完成二级认证后再次访问接口
。 - 前端将信息提示给用户,用户输入密码,调用
openSafe
接口。 - 后端比对用户输入的密码,完成二级认证,有效期为:120秒。
- 前端在 120 秒内再次调用
deleteProject
接口,尝试删除仓库。 - 后端校验会话已完成二级认证,返回:
仓库删除成功
。
指定业务标识进行二级认证
如果项目有多条业务线都需要敏感操作验证,则 StpUtil.openSafe()
无法提供细粒度的认证操作
此时我们可以指定一个业务标识来分辨不同的业务线:
// 在当前会话 开启二级认证,业务标识为client,时间为600秒
StpUtil.openSafe("client", 600);
// 获取:当前会话是否已完成指定业务的二级认证
StpUtil.isSafe("client");
// 校验:当前会话是否已完成指定业务的二级认证 ,如未认证则抛出异常
StpUtil.checkSafe("client");
// 获取当前会话指定业务二级认证剩余有效时间 (单位: 秒, 返回-2代表尚未通过二级认证)
StpUtil.getSafeTime("client");
// 在当前会话 结束指定业务标识的二级认证
StpUtil.closeSafe("client");
业务标识可以填写任意字符串,不同业务标识之间的认证互不影响,比如:
// 打开了业务标识为 client 的二级认证
StpUtil.openSafe("client");
// 判断是否处于 shop 的二级认证,会返回 false
StpUtil.isSafe("shop"); // 返回 false
// 也不会通过校验,会抛出异常
StpUtil.checkSafe("shop");
在一个方法上使用 @SaCheckSafe
注解,可以在代码进入此方法之前进行一次二级认证校验
// 二级认证:必须二级认证之后才能进入该方法
@SaCheckSafe
@RequestMapping("add")
public String add() {
return "用户增加";
}
// 指定业务类型,进行二级认证校验
@SaCheckSafe("art")
@RequestMapping("add2")
public String add2() {
return "文章增加";
}
7:账号封禁
对指定账号进行封禁:
// 封禁指定账号
StpUtil.disable(10001, 86400); 复制到剪贴板错误复制成功12
参数含义:
- 参数1:要封禁的账号id。
- 参数2:封禁时间,单位:秒,此为 86400秒 = 1天(此值为 -1 时,代表永久封禁)。
⚠️ 注意点:对于正在登录的账号,将其封禁并不会使它立即掉线,如果我们需要它即刻下线,可采用先踢再封禁的策略,例如:
// 先踢下线
StpUtil.kickout(10001);
// 再封禁账号
StpUtil.disable(10001, 86400);
待到下次登录时,我们先校验一下这个账号是否已被封禁:
// 校验指定账号是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
StpUtil.checkDisable(10001);
// 通过校验后,再进行登录:
StpUtil.login(10001);
此模块所有的方法如下:
// 封禁指定账号
StpUtil.disable(10001, 86400);
// 获取指定账号是否已被封禁 (true=已被封禁, false=未被封禁)
StpUtil.isDisable(10001);
// 校验指定账号是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
StpUtil.checkDisable(10001);
// 获取指定账号剩余封禁时间,单位:秒,如果该账号未被封禁,则返回-2
StpUtil.getDisableTime(10001);
// 解除封禁
StpUtil.untieDisable(10001);
分类封禁
有的时候,我们并不需要将整个账号禁掉,而是只禁止其访问部分服务。
假设我们在开发一个电商系统,对于违规账号的处罚,我们设定三种分类封禁:
- 1、封禁评价能力:账号A 因为多次虚假好评,被限制订单评价功能。
- 2、封禁下单能力:账号B 因为多次薅羊毛,被限制下单功能。
- 3、封禁开店能力:账号C 因为店铺销售假货,被限制开店功能。
相比于封禁账号的一刀切处罚,这里的关键点在于:每一项能力封禁的同时,都不会对其它能力造成影响。
也就是说我们需要一种只对部分服务进行限制的能力,对应到代码层面,就是只禁止部分接口的调用。
// 封禁指定用户评论能力,期限为 1天
StpUtil.disable(10001, "comment", 86400);
参数释义:
- 参数1:要封禁的账号id。
- 参数2:针对这个账号,要封禁的服务标识(可以是任意的自定义字符串)。
- 参数3:要封禁的时间,单位:秒,此为 86400秒 = 1天(此值为 -1 时,代表永久封禁)。
分类封禁模块所有可用API:
/*
* 以下示例中:"comment"=评论服务标识、"place-order"=下单服务标识、"open-shop"=开店服务标识
*/
// 封禁指定用户评论能力,期限为 1天
StpUtil.disable(10001, "comment", 86400);
// 在评论接口,校验一下,会抛出异常:`DisableServiceException`,使用 e.getService() 可获取业务标识 `comment`
StpUtil.checkDisable(10001, "comment");
// 在下单时,我们校验一下 下单能力,并不会抛出异常,因为我们没有限制其下单功能
StpUtil.checkDisable(10001, "place-order");
// 现在我们再将其下单能力封禁一下,期限为 7天
StpUtil.disable(10001, "place-order", 86400 * 7);
// 然后在下单接口,我们添加上校验代码,此时用户便会因为下单能力被封禁而无法下单(代码抛出异常)
StpUtil.checkDisable(10001, "place-order");
// 但是此时,用户如果调用开店功能的话,还是可以通过,因为我们没有限制其开店能力 (除非我们再调用了封禁开店的代码)
StpUtil.checkDisable(10001, "open-shop");
通过以上示例,你应该大致可以理解 业务封禁 -> 业务校验
的处理步骤。
有关分类封禁的所有方法:
// 封禁:指定账号的指定服务
StpUtil.disable(10001, "<业务标识>", 86400);
// 判断:指定账号的指定服务 是否已被封禁 (true=已被封禁, false=未被封禁)
StpUtil.isDisable(10001, "<业务标识>");
// 校验:指定账号的指定服务 是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
StpUtil.checkDisable(10001, "<业务标识>");
// 获取:指定账号的指定服务 剩余封禁时间,单位:秒(-1=永久封禁,-2=未被封禁)
StpUtil.getDisableTime(10001, "<业务标识>");
// 解封:指定账号的指定服务
StpUtil.untieDisable(10001, "<业务标识>");
阶梯封禁
对于多次违规的用户,我们常常采取阶梯处罚的策略,这种 “阶梯” 一般有两种形式:
- 处罚时间阶梯:首次违规封禁 1 天,第二次封禁 7 天,第三次封禁 30 天,依次顺延……
- 处罚力度阶梯:首次违规消息提醒、第二次禁言禁评论、第三次禁止账号登录,等等……
基于处罚时间的阶梯,我们只需在封禁时 StpUtil.disable(10001, 86400)
传入不同的封禁时间即可,下面我们着重探讨一下基于处罚力度的阶梯形式。
假设我们在开发一个论坛系统,对于违规账号的处罚,我们设定三种力度:
- 1、轻度违规:封禁其发帖、评论能力,但允许其点赞、关注等操作。
- 2、中度违规:封禁其发帖、评论、点赞、关注等一切与别人互动的能力,但允许其浏览帖子、浏览评论。
- 3、重度违规:封禁其登录功能,限制一切能力。
解决这种需求的关键在于,我们需要把不同处罚力度,量化成不同的处罚等级,比如上述的 轻度
、中度
、重度
3 个力度, 我们将其量化为一级封禁
、二级封禁
、三级封禁
3个等级,数字越大代表封禁力度越高。
然后我们就可以使用阶梯封禁的API,进行鉴权了:
// 阶梯封禁,参数:封禁账号、封禁级别、封禁时间
StpUtil.disableLevel(10001, 3, 10000);
// 获取:指定账号封禁的级别 (如果此账号未被封禁则返回 -2)
StpUtil.getDisableLevel(10001);
// 判断:指定账号是否已被封禁到指定级别,返回 true 或 false
StpUtil.isDisableLevel(10001, 3);
// 校验:指定账号是否已被封禁到指定级别,如果已达到此级别(例如已被3级封禁,这里校验是否达到2级),则抛出异常 `DisableServiceException`
StpUtil.checkDisableLevel(10001, 2);
注意点:DisableServiceException
异常代表当前账号未通过封禁校验,可以:
- 通过
e.getLevel()
获取这个账号实际被封禁的等级。 - 通过
e.getLimitLevel()
获取这个账号在校验时要求低于的等级。当Level >= LimitLevel
时,框架就会抛出异常。
如果业务足够复杂,我们还可能将 分类封禁 和 阶梯封禁 组合使用:
// 分类阶梯封禁,参数:封禁账号、封禁服务、封禁级别、封禁时间
StpUtil.disableLevel(10001, "comment", 3, 10000);
// 获取:指定账号的指定服务 封禁的级别 (如果此账号未被封禁则返回 -2)
StpUtil.getDisableLevel(10001, "comment");
// 判断:指定账号的指定服务 是否已被封禁到指定级别,返回 true 或 false
StpUtil.isDisableLevel(10001, "comment", 3);
// 校验:指定账号的指定服务 是否已被封禁到指定级别(例如 comment服务 已被3级封禁,这里校验是否达到2级),如果已达到此级别,则抛出异常
StpUtil.checkDisableLevel(10001, "comment", 2);
使用注解
首先我们需要注册 Sa-Token 全局拦截器,然后我们就可以使用以下注解校验账号是否封禁
// 校验当前账号是否被封禁,如果已被封禁会抛出异常,无法进入方法
@SaCheckDisable
@PostMapping("send")
public SaResult send() {
// ...
return SaResult.ok();
}
// 校验当前账号是否被封禁 comment 服务,如果已被封禁会抛出异常,无法进入方法
@SaCheckDisable("comment")
@PostMapping("send")
public SaResult send() {
// ...
return SaResult.ok();
}
// 校验当前账号是否被封禁 comment、place-order、open-shop 等服务,指定多个值,只要有一个已被封禁,就无法进入方法
@SaCheckDisable({"comment", "place-order", "open-shop"})
@PostMapping("send")
public SaResult send() {
// ...
return SaResult.ok();
}
// 阶梯封禁,校验当前账号封禁等级是否达到5级,如果达到则抛出异常
@SaCheckDisable(level = 5)
@PostMapping("send")
public SaResult send() {
// ...
return SaResult.ok();
}
// 分类封禁 + 阶梯封禁 校验:校验当前账号的 comment 服务,封禁等级是否达到5级,如果达到则抛出异常
@SaCheckDisable(value = "comment", level = 5)
@PostMapping("send")
public SaResult send() {
// ...
return SaResult.ok();
}
这样即可保证封禁数据同步插入到缓存和数据库中,但是还有一个问题,如果我们的程序或缓存中间件重启了,导致缓存数据丢失, 那再调用 StpUtil.checkDisable(10001)
代码将没有效果,无法约束到此用户。
比较次的解决方案是在程序启动时,读取数据库中所有封禁信息同步到缓存中去,但是如果封禁记录较多这样将会严重拖慢程序启动时间。
Sa-Token 提供一种方案,可以在你调用 StpUtil.checkDisable(10001)
校验封禁时才会触发查询数据库 10001 账号到底有没有被封禁。 你只需要实现 StpInterface
的 isDisabled
方法即可,例:
@Component
public class StpInterfaceImpl implements StpInterface {
/**
* 返回指定账号 id 是否被封禁
*
* @param loginId 账号id
* @param service 业务标识符
* @return 描述该账号是否封禁的包装信息对象
*/
public SaDisableWrapperInfo isDisabled(Object loginId, String service) {
// 查库操作 ... (此处仅做示例代码)
return SaDisableWrapperInfo.createDisabled(86400, 1);
}
}
该方法返回一个 SaDisableWrapperInfo
实例对象,用来描述指定账号是否已被封禁,一般有以下几种写法:
// 标准写法:new 对象返回,参数为:是否被封禁、封禁时间(秒)、封禁等级
public SaDisableWrapperInfo isDisabled(Object loginId, String service) {
return new SaDisableWrapperInfo(true, 86400, 1);
}
// 快捷写法:被封禁,解封倒计时86400秒,封禁等级1
public SaDisableWrapperInfo isDisabled(Object loginId, String service) {
return SaDisableWrapperInfo.createDisabled(86400, 1);
}
// 快捷写法:未被封禁
public SaDisableWrapperInfo isDisabled(Object loginId, String service) {
return SaDisableWrapperInfo.createNotDisabled();
}
// 快捷写法:未被封禁,且将查询结果保存到缓存中,ttl为86400,改时间内不再重复进入 isDisabled 方法
public SaDisableWrapperInfo isDisabled(Object loginId, String service) {
return SaDisableWrapperInfo.createNotDisabled(86400);
}
8:会话查询
单账号会话查询
使用 StpUtil.getTerminalListByLoginId( loginId )
可获取指定账号已登录终端列表信息,例如:
public static void main(String[] args) {
System.out.println("账号 10001 登录设备信息:");
List<SaTerminalInfo> terminalList = StpUtil.getTerminalListByLoginId(10001);
for (SaTerminalInfo ter : terminalList) {
System.out.println("登录index=" + ter.getIndex() + ", 设备type=" + ter.getDeviceType() + ", token=" + ter.getTokenValue() + ", 登录time=" + ter.getCreateTime());
}
}
terminal.getIndex(); // 登录会话索引值 (该账号第几个登录的设备)
terminal.getDeviceType(); // 所属设备类型,例如:PC、WEB、HD、MOBILE、APP
terminal.getTokenValue(); // 此次登录的token值
terminal.getCreateTime(); // 登录时间, 13位时间戳
terminal.getDeviceId(); // 设备id, 设备唯一标识
terminal.getExtra("key"); // 此次登录的额外自定义参数
Extra
自定义参数可以在登录时通过如下方式指定:
StpUtil.login(10001, new SaLoginParameter().setTerminalExtra("key", "value"));
全部会话检索
// 查询所有已登录的 Token
StpUtil.searchTokenValue(String keyword, int start, int size, boolean sortType);
// 查询所有 Account-Session 会话
StpUtil.searchSessionId(String keyword, int start, int size, boolean sortType);
// 查询所有 Token-Session 会话
StpUtil.searchTokenSessionId(String keyword, int start, int size, boolean sortType);
keyword
: 查询关键字,只有包括这个字符串的 token 值才会被查询出来。start
: 数据开始处索引。size
: 要获取的数据条数 (值为-1代表一直获取到末尾)。sortType
: 排序方式(true=正序:先登录的在前,false=反序:后登录的在前)。
// 查询 value 包括 1000 的所有 token,结果集从第 0 条开始,返回 10 条
List<String> tokenList = StpUtil.searchTokenValue("1000", 0, 10, true);
for (String token : tokenList) {
System.out.println(token);
}
深入:
StpUtil.searchTokenValue
和StpUtil.searchSessionId
的区别?
- StpUtil.searchTokenValue 查询的是登录产生的所有 Token。
- StpUtil.searchSessionId 查询的是所有已登录账号会话id。
举个例子,项目配置如下:
sa-token:
# 允许同一账号在多个设备一起登录
is-concurrent: true
# 同一账号每次登录产生不同的token
is-share: false
假设此时账号A在 电脑、手机、平板 依次登录(共3次登录),账号B在 电脑、手机 依次登录(共2次登录),那么:
StpUtil.searchTokenValue
将返回一共 5 个Token。StpUtil.searchSessionId
将返回一共 2 个 SessionId。
综上,若要遍历系统所有已登录的会话,代码将大致如下:
// 获取所有已登录的会话id
List<String> sessionIdList = StpUtil.searchSessionId("", 0, -1, false);
for (String sessionId : sessionIdList) {
// 根据会话id,查询对应的 SaSession 对象,此处一个 SaSession 对象即代表一个登录的账号
SaSession session = StpUtil.getSessionBySessionId(sessionId);
// 查询这个账号都在哪些设备登录了,依据上面的示例,账号A 的 SaTerminalInfo 数量是 3,账号B 的 SaTerminalInfo 数量是 2
List<SaTerminalInfo> terminalList = session.terminalListCopy();
System.out.println("会话id:" + sessionId + ",共在 " + terminalList.size() + " 设备登录");
}
由于会话查询底层采用了遍历方式获取数据,当数据量过大时此操作将会比较耗时,有多耗时呢?这里提供一份参考数据:
- 单机模式下:百万会话取出10条 Token 平均耗时
0.255s
。 - Redis模式下:百万会话取出10条 Token 平均耗时
3.322s
。
请根据业务实际水平合理调用API。
9:全局监听器和拦截器
package com.cui.satokendemo.listener;
/**
* @author cui haida
* 2025/3/30
*/
import cn.dev33.satoken.listener.SaTokenListener;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import org.springframework.stereotype.Component;
/**
* 自定义侦听器的实现
* @author cuihaida
*/
@Component
public class MySaTokenListener implements SaTokenListener {
/** 每次登录时触发 */
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter) {
System.out.println("---------- 自定义侦听器实现 doLogin");
}
/** 每次注销时触发 */
@Override
public void doLogout(String loginType, Object loginId, String tokenValue) {
System.out.println("---------- 自定义侦听器实现 doLogout");
}
/** 每次被踢下线时触发 */
@Override
public void doKickout(String loginType, Object loginId, String tokenValue) {
System.out.println("---------- 自定义侦听器实现 doKickout");
}
/** 每次被顶下线时触发 */
@Override
public void doReplaced(String loginType, Object loginId, String tokenValue) {
System.out.println("---------- 自定义侦听器实现 doReplaced");
}
/** 每次被封禁时触发 */
@Override
public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {
System.out.println("---------- 自定义侦听器实现 doDisable");
}
/** 每次被解封时触发 */
@Override
public void doUntieDisable(String loginType, Object loginId, String service) {
System.out.println("---------- 自定义侦听器实现 doUntieDisable");
}
/** 每次二级认证时触发 */
@Override
public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) {
System.out.println("---------- 自定义侦听器实现 doOpenSafe");
}
/** 每次退出二级认证时触发 */
@Override
public void doCloseSafe(String loginType, String tokenValue, String service) {
System.out.println("---------- 自定义侦听器实现 doCloseSafe");
}
/** 每次创建Session时触发 */
@Override
public void doCreateSession(String id) {
System.out.println("---------- 自定义侦听器实现 doCreateSession");
}
/** 每次注销Session时触发 */
@Override
public void doLogoutSession(String id) {
System.out.println("---------- 自定义侦听器实现 doLogoutSession");
}
/** 每次Token续期时触发 */
@Override
public void doRenewTimeout(String tokenValue, Object loginId, long timeout) {
System.out.println("---------- 自定义侦听器实现 doRenewTimeout");
}
}
package com.cui.satokendemo.config;
/**
* @author cui haida
* 2025/3/30
*/
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* [Sa-Token 权限认证] 配置类
* @author cuihaida
*/
@Configuration
public class SaTokenConfigure {
/**
* 注册 [Sa-Token全局过滤器]
*/
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
// 指定 拦截路由 与 放行路由
/* 排除掉 /favicon.ico */
.addInclude("/**").addExclude("/favicon.ico")
// 认证函数: 每次请求执行
.setAuth(obj -> {
System.out.println("---------- 进入Sa-Token全局认证 -----------");
// 登录认证 -- 拦截所有路由,并排除/user/doLogin 用于开放登录
SaRouter.match("/**", "/user/doLogin", () -> StpUtil.checkLogin());
})
// 异常处理函数:每次认证函数发生异常时执行此函数
.setError(e -> {
System.out.println("---------- 进入Sa-Token异常处理 -----------");
return SaResult.error(e.getMessage());
})
// 前置函数:在每次认证函数之前执行(BeforeAuth 不受 includeList 与 excludeList 的限制,所有请求都会进入)
.setBeforeAuth(r -> {
// ---------- 设置一些安全响应头 ----------
SaHolder.getResponse()
// 服务器名称
.setServer("sa-server")
// 是否可以在iframe显示视图: DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以
.setHeader("X-Frame-Options", "SAMEORIGIN")
// 是否启用浏览器默认XSS防护: 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时,停止渲染页面
.setHeader("X-XSS-Protection", "1; mode=block")
// 禁用浏览器内容嗅探
.setHeader("X-Content-Type-Options", "nosniff")
;
})
;
}
}