登录注册番外篇(二) - Sa-Token:会话全生命周期管理

第一章. 多端登录策略:两个配置项决定一切

环境版本

组件版本
JDK17
Spring Boot3.4.x
Sa-Token1.44.0
Redis7.x

阶段式学习路径

在番外篇(一)中,我们用 StpUtil.login(10001) 完成了最基础的登录,并通过 Redis 实验观察了会话数据的存储结构。但有一个关键问题一直没有回答:同一个账号能不能在多个设备上同时登录?如果能,上限是多少?如果不能,新登录是挤掉旧登录还是直接拒绝?

本章我们将深入 Sa-Token 的多端登录策略,理解两个配置项背后的组合逻辑,并把番外篇(一)中的登录接口升级为支持设备类型和差异化策略的完整版本。

在开始之前,需要先对 application.yml 做一处调整。番外篇(一)中我们配置的是 is-share: true,这会导致同一账号多次登录拿到同一个 Token,无法演示多设备独立会话的效果。将它改为 false

📄 src/main/resources/application.yml(修改)

1
2
3
sa-token:
is-concurrent: true
is-share: false # 从 true 改为 false,每次登录生成独立 Token

这一改动的效果是:同一账号在不同设备上登录时,每次都会拿到一个全新的 Token,旧 Token 不失效。我们在后面的测试中会直接看到这个变化。


1.1. 两个配置项决定登录行为

is-concurrentis-share 是两个相互独立的维度,它们的组合决定了整个项目的多端登录策略。

is-concurrent 回答的问题是"允不允许同时在多个设备上登录"。设置为 true 时,同一账号可以在手机、电脑、平板上同时保持登录状态;设置为 false 时,新设备一登录,旧设备的会话立刻失效,同一时刻只有一个有效会话存在。

is-shareis-concurrent=true 的前提下才有意义,它回答的是"多个设备是否共用同一个 Token"。设置为 true 时,账号的多次登录会拿到同一个 Token 值(前提是之前的 Token 仍在有效期内);设置为 false 时,每次登录都生成一个全新的 Token,各设备的会话彼此独立。

两者组合,产生三种实际可用的登录模式:

is-concurrentis-share登录模式适用场景
truetrue多端共享 Token对安全要求不高、希望减少 Token 数量的场景
truefalse多端独立 Token最常用,既允许多设备在线,又能独立管理各设备会话
false单端登录对安全敏感的场景,如金融账户

我们目前的配置是第二种模式——多端独立 Token,也是生产项目中最常见的选择。

在继续之前,先用一个 Redis 实验直接感受 is-share 前后的差异。

实验:观察 is-share 切换对 Redis 数据的影响

is-share 临时改回 true,用同一账号连续登录两次,然后查看 Redis:

1
2
# 查找该账号下的所有 Token 关联键
keys sa-token:login:token-list:login:10001

你会看到列表中只有一条 Token 记录——两次登录共用了同一个 Token 值。

现在将 is-share 改为 false,再次连续登录两次,重复查询:

1
keys sa-token:login:token-list:login:10001

这次列表中出现了两条不同的 Token 记录。每次登录都独立生成了一个新的 Token,两个 Token 同时有效,互不影响。

这个实验印证了配置含义,也解释了为什么本系列后续的多设备测试必须在 is-share: false 下进行——只有每次登录拿到独立 Token,我们才能观察到"设备 A 被踢下线但设备 B 不受影响"的真实效果。


1.2. SaLoginParameter:登录时的动态参数

全局配置是项目级的默认策略,但现实中不同角色的登录行为往往不同——管理员可能需要严格的单端限制,普通用户可以多端并行。如果只靠 application.yml,就只能给所有账号统一一套规则。

Sa-Token 为此提供了 SaLoginParameter,它是登录时的动态参数对象,其中的任何配置都会覆盖 application.yml 中对应的全局配置,且只对当次登录生效。使用时将它作为 StpUtil.login() 的第二个参数传入:

1
2
3
4
5
6
7
StpUtil.login(userId, new SaLoginParameter()
.setDeviceType("PC") // 设备类型
.setIsConcurrent(true) // 是否允许并发(覆盖全局 is-concurrent)
.setIsShare(false) // 是否共用 Token(覆盖全局 is-share)
.setMaxLoginCount(3) // 最大同时在线设备数,-1 不限制
.setTimeout(60 * 60 * 24 * 7) // Token 有效期(秒),覆盖全局 timeout
);

以下是本章会用到的核心配置项说明:

配置方法对应全局配置说明
setDeviceType(String)无全局对应设置此次登录的设备类型标识,如 PC / APP / PAD
setIsConcurrent(Boolean)is-concurrent是否允许同一账号并发登录
setIsShare(Boolean)is-share并发登录时是否共用 Token
setMaxLoginCount(int)无全局对应最大同时在线设备数,超限时自动踢掉最早的会话
setTimeout(long)timeout本次登录 Token 的有效期(秒)

理解了 SaLoginParameter 的定位之后,我们来升级登录接口。


1.3. 升级登录接口:双账号 + 设备类型 + 差异化策略

番外篇(一)的登录接口只支持一个硬编码账号 admin/123456,且所有用户共用同一个 userId 10001。现在我们把它升级为支持多账号、多设备类型、按角色差异化配置的完整版本。

📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改,替换 login 方法)

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
package com.example.authsatoken.controller;

import cn.dev33.satoken.stp.SaLoginParameter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/auth")
public class LoginController {

// 模拟用户数据:账号 → [密码, userId]
// 实际项目中应查询数据库,这里仅用于聚焦 Sa-Token 本身的行为
private static final Map<String, long[]> USER_DB = Map.of(
"admin", new long[]{123456L, 10001L},
"user", new long[]{123456L, 10002L}
);

/**
* 登录接口(升级版)
* 支持设备类型参数,管理员与普通用户采用不同的登录策略
*
* @param username 用户名
* @param password 密码
* @param device 设备类型(PC / APP / PAD),默认 PC
*/
@PostMapping("/login")
public SaResult login(@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false, defaultValue = "PC") String device) {
// 1. 校验账号是否存在
if (!USER_DB.containsKey(username)) {
return SaResult.error("用户名或密码错误");
}
long[] userInfo = USER_DB.get(username);
long storedPassword = userInfo[0];
long userId = userInfo[1];

// 2. 校验密码(实际项目中应对比哈希值,这里简化演示)
if (storedPassword != Long.parseLong(password)) {
return SaResult.error("用户名或密码错误");
}

// 3. 根据角色构建差异化的登录参数
boolean isAdmin = "admin".equals(username);
SaLoginParameter loginParam = new SaLoginParameter()
.setDeviceType(device)
// 管理员:不允许并发,严格单端;普通用户:允许多端
.setIsConcurrent(!isAdmin)
// 管理员最多同时在线 1 个设备;普通用户最多 2 个
.setMaxLoginCount(isAdmin ? 1 : 2);

// 4. 执行登录,返回 Token 给前端
StpUtil.login(userId, loginParam);
return SaResult.ok("登录成功").setData(StpUtil.getTokenValue());
}

/**
* 注销当前会话
*/
@PostMapping("/logout")
public SaResult logout() {
StpUtil.logout();
return SaResult.ok("已退出登录");
}

/**
* 查询当前是否已登录(不要求登录即可访问)
*/
@GetMapping("/isLogin")
public SaResult isLogin() {
return SaResult.ok(StpUtil.isLogin());
}
}

有几个关键点值得注意。

USER_DBMap.of() 模拟了一个内存数据库:admin 账号对应 userId 10001user 账号对应 userId 10002,两者完全独立。这样后续测试多账号并发、互相踢人等场景时逻辑才是自洽的。

登录参数的差异化体现在第 3 步:通过 isAdmin 这一个布尔值,admin 账号登录时 setIsConcurrent(false)(覆盖全局的 true),强制单端互斥;user 账号保持 setIsConcurrent(true),允许多端在线。两个账号使用同一个接口,却执行了截然不同的登录策略。

setMaxLoginCount(isAdmin ? 1 : 2) 只在 isConcurrent=true, isShare=false 时才有意义。对于 admin 账号,isConcurrent 已经是 false,框架层面天然只允许一个会话,这里的 maxLoginCount=1 是为了语义对称。对于 user 账号,maxLoginCount=2 意味着当第 3 个设备登录时,框架会自动将登录时间最早的那个 Token 踢下线。


1.4. 验证同端互斥效果

现在重启项目,通过一组测试来验证多端登录策略的完整行为。测试过程中在 Postman 的 Header 中携带 satoken: <Token值> 发起请求。

步骤 1:user 账号以 PC 端登录,记录返回的 Token-A

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=PC

响应中 data 字段的值就是 Token-A,复制备用。

步骤 2:同一 user 账号以 APP 端登录,记录返回的 Token-B

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=APP

此时 Token-A 和 Token-B 同时有效——两个不同设备类型的会话互不干扰。

步骤 3:再次以 PC 端登录,记录返回的 Token-C

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=PC

步骤 4:验证 Token-A 已被顶替,Token-B 仍然有效

用 Token-A 访问任意需要登录的接口(比如后续章节会添加的 /auth/info):

1
2
GET http://localhost:8081/auth/isLogin
Header: satoken: <Token-A的值>

此步骤验证的是 replaced(顶替下线)行为。当前 isLogin 接口不主动校验登录,返回的是 false 而非异常。第四章添加全局异常处理器后,携带已顶替 Token 访问需要登录的接口,将收到 { "code": 401, "msg": "您的账号已在其他设备登录,当前会话已下线" } 的 JSON 响应。

用 Token-B(APP 端)重复上述请求,仍然返回 true——APP 端完全没有受到影响。这就是同端互斥的核心表现:同设备类型互斥,不同设备类型共存

步骤 5:验证 admin 账号的单端限制

1
POST http://localhost:8081/auth/login?username=admin&password=123456&device=PC

记录 Token-D,然后再次以同账号任意设备类型登录:

1
POST http://localhost:8081/auth/login?username=admin&password=123456&device=APP

用 Token-D 访问接口,会发现它已经失效——admin 账号的 isConcurrent=false 使得任何新登录都会顶替所有旧会话,无论设备类型是否相同。

以下是测试结果的完整矩阵,对照验证:

操作Token-A (user/PC)Token-B (user/APP)Token-C (user/PC)Token-D (admin/PC)
user/PC 登录后✅ 有效
user/APP 登录后✅ 有效✅ 有效
user/PC 再次登录后❌ 被顶替✅ 有效✅ 有效
admin/APP 登录后❌ 无关❌ 无关❌ 无关❌ 被顶替

1.5. 本章总结

本章回顾

本章完成了登录策略从"全局统一"到"按角色差异化"的升级。我们首先通过 Redis 实验直观感受了 is-share 切换前后的存储差异,确立了多端独立 Token 作为后续演示基础的必要性。随后系统介绍了 SaLoginParameter 的五个核心配置方法,理解了它作为"登录时动态覆盖全局配置"的定位。在代码层面,登录接口从单一硬编码账号升级为双账号独立 userId 的模拟数据结构,并通过三元表达式实现了"管理员单端、普通用户多端(上限 2 个)"的差异化策略。最后通过多步骤测试验证了同端互斥的完整行为,形成了可供对照的测试矩阵。

核心汇总表

配置组合行为典型场景
is-concurrent=true, is-share=true多设备共用一个 Token简单项目,对设备隔离要求低
is-concurrent=true, is-share=false多设备各持独立 Token主流选择,支持独立设备管理
is-concurrent=false新登录挤掉一切旧登录金融、安全敏感场景

第二章. Token 生命周期管理

阶段式学习路径

第一章解决了"同一个账号能不能多端登录"的问题。但还有一个问题没有回答:Token 什么时候会自然失效?用户在手机上登录后,放一周不操作,再打开 App 还需要重新登录吗?如果需要,失效的时机是固定的 7 天,还是"只要你还在用就一直有效"?

这就是本章要讲的——Token 的生命周期。Sa-Token 提供了两个独立的有效期维度,它们可以单独使用,也可以组合叠加,覆盖绝大多数项目对会话时效的业务需求。本章最后还会讲解"记住我"功能的实现原理,以及前后端分离环境下的替代方案。


2.1. 两个有效期维度

Sa-Token 对 Token 有效期的控制由两个配置项决定,它们回答的是两个完全不同的问题。

timeout 回答的是"Token 的绝对寿命是多少"。一个 Token 被创建出来的那一刻,它的倒计时就开始了。无论这段时间内用户是否活跃、访问了多少次接口,只要 timeout 指定的秒数一到,Token 就会失效。它是不可续签的——每次请求都不会重置这个倒计时。

active-timeout 回答的是"用户多久不操作才算不活跃"。每次携带有效 Token 访问接口时,框架会自动将这个计时器重置为初始值。只要用户持续在使用,Token 就会一直续签下去;一旦超过 active-timeout 指定的秒数没有任何请求,Token 才会被冻结失效。

两个配置项都在 application.yml 中设置:

1
2
3
4
5
sa-token:
# Token 绝对有效期,单位:秒,-1 代表永久有效
timeout: 2592000 # 30 天
# Token 最低活跃频率,单位:秒,-1 代表不启用活跃检查
active-timeout: 1800 # 30 分钟

active-timeout 默认值为 -1(不启用)。只配置 timeout 是完全合法的用法,大多数简单项目也只用 timeout 就够了。


2.2. 两个维度的组合逻辑

单独理解两个配置项并不难,真正需要思考的是它们 叠加在一起时的行为

当两个配置同时启用时,框架采用的是 双重门槛,任意一个触发则失效 的策略。也就是说,Token 必须同时满足"未超过绝对有效期"和"最近一次请求距今未超过活跃期",才会被认为是有效的。

这产生了几个经典的业务组合:

“7 天内免登录,但连续 30 分钟不操作自动退出”

1
2
3
sa-token:
timeout: 604800 # 7 天绝对上限
active-timeout: 1800 # 30 分钟活跃期

用户登录后,只要每隔不超过 30 分钟发起一次请求,Token 就会一直续签;但无论续签多少次,7 天到期后 Token 必然失效,用户需要重新登录。这是大多数 ToC 产品的标准策略。

“永不过期,但长时间不用会掉线”

1
2
3
sa-token:
timeout: -1 # 绝对有效期关闭
active-timeout: 7200 # 2 小时活跃期

Token 没有固定的寿命,只要用户保持活跃就一直在线。适合内部工具、管理后台等对持久登录有要求、但也不希望长时间挂机占用会话资源的场景。

“固定 30 天有效,不论是否活跃”

1
2
3
sa-token:
timeout: 2592000 # 30 天
active-timeout: -1 # 不启用活跃检查

最简单的策略,Token 在 30 天内始终有效,不受访问频率影响。适合移动端 App、"记住我"场景。


2.3. 手动操作 Token 有效期

除了依靠框架自动管理,Sa-Token 也暴露了一组 API,允许在代码中手动查询和修改 Token 的有效期。

查询剩余有效期

1
2
3
4
5
6
7
// 获取当前 Token 的绝对有效期剩余秒数
// 返回 -1 表示永久有效,返回 -2 表示 Token 不存在或已失效
long timeout = StpUtil.getTokenTimeout();

// 获取当前 Token 的活跃有效期剩余秒数
// 返回 -1 表示未启用 active-timeout,返回 -2 表示已冻结
long activeTimeout = StpUtil.getTokenActiveTimeout();

手动续签

1
2
3
4
5
6
7
// 续签当前 Token 的活跃有效期(重置 active-timeout 倒计时)
// 通常不需要手动调用,框架在每次请求时会自动续签
// 适用场景:某些不经过 Sa-Token 拦截器的后台任务,需要主动保持会话活跃
StpUtil.updateLastActiveToNow();

// 将当前 Token 的绝对有效期续签为指定秒数
StpUtil.renewTimeout(86400); // 续签 1 天

在登录时指定本次的有效期

SaLoginParameter 也可以单独指定这一次登录的 Token 有效期,覆盖全局的 timeout 配置:

1
2
// 指定此次登录的 Token 绝对有效期为 7 天
StpUtil.login(10001, new SaLoginParameter().setTimeout(60 * 60 * 24 * 7));

这个用法在第一章已经出现过。结合本章的知识,你可以理解它的完整含义:只影响这一次登录生成的 Token,项目中其他账号的登录行为不受影响。


2.4. 为 /auth/info 接口添加有效期信息

为了让后续章节的测试有一个统一的"查看当前会话状态"的入口,我们现在向 LoginController 中添加 /auth/info 接口,并在返回数据中包含 Token 的剩余有效期信息。

📄 src/main/java/com/example/authsatoken/controller/LoginController.java(追加方法)

在已有的 isLogin 方法下方追加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import cn.dev33.satoken.stp.SaTokenInfo;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* 获取当前登录会话的详细信息
* 包含 loginId、tokenValue、剩余有效期等,此接口需要已登录才能访问
*/
@GetMapping("/info")
public SaResult info() {
StpUtil.checkLogin();
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();

Map<String, Object> data = new LinkedHashMap<>();
data.put("loginId", tokenInfo.loginId);
data.put("tokenValue", tokenInfo.tokenValue);
data.put("timeout", StpUtil.getTokenTimeout()); // 绝对有效期剩余秒数
data.put("activeTimeout",StpUtil.getTokenActiveTimeout()); // 活跃有效期剩余秒数
return SaResult.data(data);
}

登录后访问此接口,响应大致如下:

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"msg": "ok",
"data": {
"loginId": "10002",
"tokenValue": "xxxx-xxxx-xxxx-xxxx",
"timeout": 2591856,
"activeTimeout": 1793
}
}

timeout 会随着时间流逝单调递减,始终不重置;activeTimeout 则每次调用此接口后都会被重置回 1800,因为访问本身就是一次"活跃"行为。


2.5. [记住我] 模式

几乎所有带登录页面的产品都有这个按钮。勾选之后,即使关闭浏览器再重新打开,依然处于登录状态,不需要再次输入密码。

Sa-Token 通过 StpUtil.login() 的第二个参数支持这一功能:

1
2
3
4
5
// 记住我:关闭浏览器后 Token 依然有效
StpUtil.login(10001, true);

// 不记住我:关闭浏览器后 Token 消失,会话失效
StpUtil.login(10001, false);

这个功能的底层依赖浏览器 Cookie 的两种生命周期:

  • 持久 Cookie:有一个具体的过期时间。浏览器关闭后重新打开,Cookie 依然存在,Token 照常有效。
  • 临时 Cookie:有效期为"本次会话"。只要浏览器窗口关闭,Cookie 就随之消失,Token 再也无法被携带到服务端,会话自然失效。

StpUtil.login(10001, true) 对应持久 Cookie,StpUtil.login(10001, false) 对应临时 Cookie。框架在登录时根据这个参数决定向浏览器写入哪种 Cookie。

在我们现有的登录接口中,可以将 [记住我] 作为一个请求参数接入:

📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改 login 方法,追加 rememberMe 参数)

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
@PostMapping("/login")
public SaResult login(@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false, defaultValue = "PC") String device,
@RequestParam(required = false, defaultValue = "false") boolean rememberMe) {

if (!USER_DB.containsKey(username)) {
return SaResult.error("用户名或密码错误");
}
long[] userInfo = USER_DB.get(username);
if (userInfo[0] != Long.parseLong(password)) {
return SaResult.error("用户名或密码错误");
}

long userId = userInfo[1];
boolean isAdmin = "admin".equals(username);

SaLoginParameter loginParam = new SaLoginParameter()
.setDeviceType(device)
.setIsConcurrent(!isAdmin)
.setMaxLoginCount(isAdmin ? 1 : 2)
// 将前端传入的 rememberMe 映射为持久/临时 Cookie
.setIsLastingCookie(rememberMe);

StpUtil.login(userId, loginParam);
return SaResult.ok("登录成功").setData(StpUtil.getTokenValue());
}

setIsLastingCookie(true) 等价于 StpUtil.login(id, true),两者最终效果相同,只是前者通过 SaLoginParameter 传入,可以和其他登录参数组合使用。

前后端分离模式下如何实现 [记住我]

Cookie 虽好,却无法在前后端分离环境下使用——App、小程序等客户端默认没有实现 Cookie 功能,无法依赖框架自动写入和读取。这种情况下,Token 的存储和生命周期需要由 前端自己管理

在 PC 浏览器的前后端分离场景(如 Vue + Axios)下:

1
2
3
4
5
// 勾选了 [记住我]:存入 localStorage,浏览器关闭后依然保留
localStorage.setItem("satoken", "xxxx-xxxx-xxxx-xxxx");

// 未勾选 [记住我]:存入 sessionStorage,标签页关闭后自动清除
sessionStorage.setItem("satoken", "xxxx-xxxx-xxxx-xxxx");

两种环境的核心思路是一致的:持久存储 → 记住我;会话级存储 → 不记住我。只是从浏览器 Cookie 的两种模式,变成了前端存储 API 的两种选择。服务端的代码不需要做任何改动,改变的只是前端把 Token 存在哪里。

前后端分离模式下,Token 的过期仍然由服务端的 timeoutactive-timeout 控制。前端的"持久存储"只是保证了 Token 字符串不会因为关闭应用而丢失,并不会延长 Token 本身的有效期。


2.6. 本章总结

本章回顾

本章系统梳理了 Token 从创建到失效的两个时间维度。timeout 是绝对有效期,从登录时开始倒计时,不受请求频率影响;active-timeout 是活跃有效期,每次请求都会重置,长时间不操作才触发失效。两者独立配置,可以组合出"固定寿命"“活跃续签”"活跃续签 + 绝对上限"等多种策略,覆盖不同安全需求的业务场景。在代码层面,我们补充了查询和手动续签有效期的 API,新增了 /auth/info 接口作为后续测试的会话状态查询入口。最后讲解了"记住我"功能的 Cookie 实现原理,以及在前后端分离环境中用前端存储 API 替代 Cookie 的完整方案。

核心汇总表

配置项含义续签行为典型值
timeoutToken 绝对有效期不续签,倒计时不可重置2592000(30 天)
active-timeoutToken 活跃有效期每次请求自动重置1800(30 分钟)
-1关闭该维度的检查两个配置项均支持
常用 API说明
StpUtil.getTokenTimeout()查询当前 Token 绝对有效期剩余秒数
StpUtil.getTokenActiveTimeout()查询当前 Token 活跃有效期剩余秒数
StpUtil.updateLastActiveToNow()手动续签活跃有效期
StpUtil.renewTimeout(sec)将绝对有效期续签为指定秒数
[记住我] 实现方式持久登录非持久登录
传统 Cookie 模式setIsLastingCookie(true)setIsLastingCookie(false)
uni-appuni.setStorageSync()getApp().globalData
PC 浏览器前后端分离localStoragesessionStorage

第三章. 读取当前会话信息

阶段式学习路径

前两章解决了"怎么登录"和"Token 活多久"的问题。但登录成功之后,还有一个同样高频的需求没有覆盖:如何从当前请求中取出"我是谁"?

每一个需要登录的接口,几乎都要做同一件事——先拿到当前用户的 ID,再用这个 ID 去查业务数据。除此之外,你可能还需要知道当前 Token 的完整信息、这个账号在所有设备上的在线情况,甚至只是判断一下"这个请求到底有没有登录"。

本章系统梳理 Sa-Token 为此提供的全部 API,并完善 /auth/info 接口,让它成为后续章节测试中真正好用的会话状态查询入口。


3.1. 获取当前登录用户 ID

StpUtil.getLoginId() 是使用频率最高的 API,没有之一。它的返回值是 Object 类型,因为 Sa-Token 内部将 loginId 统一以字符串形式存储(我们在番外篇一中通过 Redis 实验验证过这一点),所以直接拿到的是 String

为了让调用方不必每次手动转型,Sa-Token 提供了几个带类型的重载方法:

1
2
3
4
5
6
7
8
9
10
11
// 返回 Object 类型,实际是 String
Object loginId = StpUtil.getLoginId();

// 返回 String 类型(等价于 String.valueOf(getLoginId()))
String loginIdStr = StpUtil.getLoginIdAsString();

// 返回 long 类型(等价于 Long.parseLong(getLoginId().toString()))
long loginIdLong = StpUtil.getLoginIdAsLong();

// 返回 int 类型
int loginIdInt = StpUtil.getLoginIdAsInt();

实际项目中,如果你的 userId 是数据库自增长整型,直接用 getLoginIdAsLong() 最方便;如果是 UUID 字符串,用 getLoginIdAsString()

这几个方法在未登录时都会直接抛出 NotLoginException,而不是返回 null。如果你在一个不确定是否已登录的场景下调用它们,需要先用 StpUtil.isLogin() 判断,或者用 try-catch 捕获异常。

如果你需要取的不是当前请求者的 ID,而是某个 Token 对应的 loginId(比如管理后台根据 Token 反查账号),可以使用:

1
2
// 根据 Token 值反查其对应的 loginId,Token 无效时返回 null
Object loginId = StpUtil.getLoginIdByToken("xxxx-xxxx-xxxx-xxxx");

3.2. 获取 Token 信息

StpUtil.getTokenValue() 返回当前请求携带的 Token 字符串,是最直接的方式:

1
2
// 获取当前请求的 Token 字符串
String tokenValue = StpUtil.getTokenValue();

如果你需要的不只是 Token 值,而是这个 Token 的完整元数据,使用 getTokenInfo()

1
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();

SaTokenInfo 包含以下字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"code": 200,
"msg": "ok",
"data": {
"tokenName": "satoken",
"tokenValue": "4ed22f86-9982-4c79-89f0-42047e7ce830",
"isLogin": true,
"loginId": "10002",
"loginType": "login",
"tokenTimeout": 2564356,
"sessionTimeout": 2564356,
"tokenSessionTimeout": -2,
"tokenActiveTimeout": -1,
"loginDeviceType": "PC",
"tag": null
}
}

大多数情况下,你不需要把所有字段都返回给前端,按需取用即可。


3.3. 查询登录状态

Sa-Token 提供了两个语义相近但行为截然不同的方法:

1
2
3
4
5
// 查询:当前请求是否已登录,返回 true / false,不抛异常
boolean result = StpUtil.isLogin();

// 校验:当前请求必须已登录,未登录则直接抛出 NotLoginException
StpUtil.checkLogin();

两者的核心区别在于:isLogin()查询,只告诉你结果;checkLogin()校验,不满足条件就中断请求。

什么时候用 isLogin()

适合那些"登录与否都能访问,但登录后返回更多数据"的场景:

1
2
3
4
5
6
7
8
9
@GetMapping("/article/{id}")
public SaResult getArticle(@PathVariable Long id) {
Article article = articleService.getById(id);
if (StpUtil.isLogin()) {
// 已登录用户额外返回点赞状态、收藏状态
article.setLiked(likeService.isLiked(StpUtil.getLoginIdAsLong(), id));
}
return SaResult.data(article);
}

什么时候用 checkLogin()

适合那些"没有登录就没有任何意义"的接口——直接校验,不满足就抛异常,配合第四章的全局异常处理器自动返回 401:

1
2
3
4
5
6
@GetMapping("/profile")
public SaResult getProfile() {
StpUtil.checkLogin(); // 未登录直接抛异常,后续代码不会执行
long userId = StpUtil.getLoginIdAsLong();
return SaResult.data(userService.getById(userId));
}

注解鉴权(将在第三篇讲解)的 @SaCheckLogin 本质上就是在方法入口自动调用了 checkLogin(),两者效果完全等价,只是表达方式不同。


3.4. 查询一个账号的所有在线终端

第一章的多端登录场景中,我们用 user 账号同时在 PC 和 APP 上登录,产生了两个独立的 Token。如果想知道这个账号当前一共有几个设备在线,以及每个设备对应的 Token 是什么,使用:

1
2
// 获取指定 loginId 当前所有有效 Token 的列表
List<String> tokenList = StpUtil.getTokenValueListByLoginId(10002L);

返回的列表中,每一个字符串就是一个在线设备的 Token 值。列表为空表示该账号当前没有任何在线会话。

配合 getLoginDevice() 可以进一步查询每个 Token 对应的设备类型:

1
2
// 获取当前请求 Token 登录时声明的设备类型
String device = StpUtil.getLoginDevice();

这两个 API 组合起来,就是"账号安全"页面中展示"当前登录设备列表"功能的数据来源。


3.5. 完善 /auth/info 接口

上一章我们添加了 /auth/info 接口的雏形,只返回了几个基本字段。现在结合本章的知识,将它完善为包含完整会话信息的版本。

📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改 info 方法)

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
import cn.dev33.satoken.stp.SaTokenInfo;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* 获取当前登录会话的完整信息
* 包含 loginId、设备类型、Token 值、有效期、当前账号所有在线终端
* 此接口需要已登录才能访问
*/
@GetMapping("/info")
public SaResult info() {
StpUtil.checkLogin();
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();

// 获取当前账号所有在线 Token(含当前设备)
List<String> tokenList = StpUtil.getTokenValueListByLoginId(StpUtil.getLoginId());

Map<String, Object> data = new LinkedHashMap<>();
data.put("loginId", tokenInfo.loginId);
data.put("loginDevice", tokenInfo.loginDeviceType); // 当前设备类型
data.put("tokenValue", tokenInfo.tokenValue);
data.put("timeout", tokenInfo.tokenTimeout); // 绝对有效期剩余秒数
data.put("activeTimeout", tokenInfo.tokenActiveTimeout); // 活跃有效期剩余秒数
data.put("onlineCount", tokenList.size()); // 当前账号在线设备数
data.put("tokenList", tokenList); // 所有在线设备的 Token 列表
return SaResult.data(data);
}

登录后访问此接口,响应大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"code": 200,
"msg": "ok",
"data": {
"loginId": "10002",
"loginDevice": "PC",
"tokenValue": "xxxx-xxxx-xxxx-xxxx",
"timeout": 2591743,
"activeTimeout": 1800,
"onlineCount": 2,
"tokenList": [
"aaaa-aaaa-aaaa-aaaa",
"xxxx-xxxx-xxxx-xxxx"
]
}
}

onlineCount2 说明这个账号当前有两个设备在线,tokenList 中两条记录分别对应不同的登录会话。这个接口将成为后续三章测试下线行为时最直接的观测工具——踢人之前看一次,踢人之后再看一次,变化一目了然。


3.6. 本章总结

本章回顾

本章系统梳理了登录成功后读取会话信息的全部 API。getLoginId() 系列方法解决了"取当前用户 ID"这一最高频的需求,并通过带类型的重载版本避免了手动转型的繁琐。getTokenInfo() 提供了 Token 的完整元数据,包含有效期、设备类型、登录状态等字段,满足需要展示详细会话信息的场景。isLogin()checkLogin() 的区别从语义层面做了厘清——前者是查询,后者是校验,选错了轻则逻辑错误,重则该拦截的请求没有拦截。最后补充了 getTokenValueListByLoginId() 用于查询一个账号的全部在线终端,并将 /auth/info 接口升级为包含在线终端列表的完整版本,为后续章节的下线行为测试准备好了观测入口。

核心汇总表

API返回值说明
StpUtil.getLoginId()Object(实为 String)当前登录用户 ID,未登录抛异常
StpUtil.getLoginIdAsString()String同上,自动转 String
StpUtil.getLoginIdAsLong()long同上,自动转 long
StpUtil.getLoginIdByToken(token)Object根据 Token 值反查 loginId,无效返回 null
StpUtil.getTokenValue()String当前请求携带的 Token 字符串
StpUtil.getTokenInfo()SaTokenInfoToken 完整元数据(有效期、设备类型等)
StpUtil.getLoginDevice()String当前 Token 登录时声明的设备类型
StpUtil.isLogin()boolean查询是否已登录,不抛异常
StpUtil.checkLogin()void校验必须已登录,未登录抛 NotLoginException
StpUtil.getTokenValueListByLoginId(id)List<String>指定账号当前所有在线 Token 列表
isLogin() vs checkLogin()适用场景
isLogin()登录与否都能访问,但登录后返回更多数据
checkLogin()未登录没有任何意义,直接中断请求

第四章. 全局异常处理器与统一响应格式

阶段式学习路径

前三章搭建起了登录、会话信息查询的基础能力,但有一个问题一直被我们悬置着:访问 /auth/info 这类需要登录的接口时,如果请求者没有携带 Token,会发生什么?

目前的答案是:Spring Boot 会返回一段 HTML 格式的错误页面。前端完全无法解析,也无法据此做出任何有意义的跳转或提示。更严重的是,Sa-Token 在不同的"未登录"情况下会抛出不同的异常——Token 过期、Token 被踢出、Token 被顶替,对用户来说应该是三条截然不同的提示,但现在前端收到的全是同一堆 HTML 乱码。

本章我们来彻底解决这个问题。在为第五章的下线行为测试铺路之前,我们需要先有一套能将异常转化为规范 JSON 响应的机制——全局异常处理器。


4.1. Sa-Token 会抛出哪些异常

在动手写代码之前,先建立一个完整的异常地图。Sa-Token 在认证鉴权阶段可能抛出的异常主要有以下几类:

NotLoginException:会话未通过登录校验时抛出,是使用频率最高的一个。它携带一个 type 字段,用数字标识"为什么没有登录"——这个数字叫做场景值,一共有 7 种:

场景值常量名触发条件
-1NOT_TOKEN请求中完全没有携带 Token
-2INVALID_TOKEN携带了 Token,但在 Redis 中找不到(已注销或从未存在)
-3TOKEN_TIMEOUTToken 存在,但已超过 timeout 配置的有效期
-4BE_REPLACEDToken 被同设备类型的新登录顶替
-5KICK_OUTToken 被管理员通过 kickout 强制踢下线
-6TOKEN_FREEZEToken 因超过 active-timeout 活跃频率限制而被冻结
-7NO_PREFIX配置了 Token 前缀但请求没有携带正确的前缀

场景值机制的价值在于:同样是"携带了 Token 但无法通过校验",-4 意味着"你在另一台设备上登录了",-5 意味着"管理员把你踢出去了",-6 意味着"太久没操作了"。前端可以根据不同的场景值展示完全不同的提示,甚至跳转到不同的页面——这是把一个异常拆成 7 个场景值的核心意义。

SaTokenException:Sa-Token 的基类异常,所有框架内部的运行时错误都继承自它。通常不需要单独捕获,在全局兜底处理中用它接住所有未预料到的框架异常即可。

本篇笔记目前只涉及登录会话生命周期,权限相关的 NotPermissionExceptionNotRoleException 将在第三篇引入权限体系时统一处理。


4.2. 创建全局异常处理器

com.example.authsatoken 包下新建 exception 子包,创建全局异常处理器:

📄 src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(新建)

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
package com.example.authsatoken.exception;

import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.SaTokenException;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
* 全局异常处理器
* 统一捕获 Sa-Token 抛出的认证异常,转化为规范的 JSON 响应
*
* 当前处理范围:会话认证相关异常(NotLoginException)
* 第三篇引入权限体系后,NotPermissionException / NotRoleException 将在此处追加
*/
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 捕获未登录异常
* 通过场景值区分不同的未登录原因,返回差异化的提示信息
*/
@ExceptionHandler(NotLoginException.class)
public SaResult handleNotLoginException(NotLoginException e) {
String message = switch (e.getType()) {
case NotLoginException.NOT_TOKEN -> "未提供 Token,请先登录";
case NotLoginException.INVALID_TOKEN -> "Token 无效,请重新登录";
case NotLoginException.TOKEN_TIMEOUT -> "Token 已过期,请重新登录";
case NotLoginException.BE_REPLACED -> "您的账号已在其他设备登录,当前会话已下线";
case NotLoginException.KICK_OUT -> "您已被强制下线";
case NotLoginException.TOKEN_FREEZE -> "Token 已被冻结,请稍后重试";
case NotLoginException.NO_PREFIX -> "Token 格式不正确,请重新登录";
default -> "当前会话未登录";
};
return SaResult.error(message).setCode(401);
}

/**
* 兜底:捕获所有其他 Sa-Token 框架异常
* 避免框架内部的未预期错误以 HTML 页面形式暴露给前端
*/
@ExceptionHandler(SaTokenException.class)
public SaResult handleSaTokenException(SaTokenException e) {
return SaResult.error("认证框架异常:" + e.getMessage()).setCode(500);
}
}

几个关键点说明。

@RestControllerAdvice 让这个类成为全局异常处理器,作用范围覆盖所有 @RestController。它是 @ControllerAdvice + @ResponseBody 的组合注解,确保返回值会被序列化为 JSON 而不是视图。

@ExceptionHandler(NotLoginException.class) 声明只处理 NotLoginException 类型的异常,其他类型的异常不受影响,仍然按 Spring 默认机制处理(或被其他 @ExceptionHandler 接管)。

switch 表达式使用的是 Java 14 正式引入、Java 17 标准化的语法。每个 -> 分支直接返回字符串值,不需要 break,比传统 switch-case 更简洁,也不容易出现 fall-through 问题。e.getType() 返回的是 int 类型的场景值,与 NotLoginException 的静态常量完全对应。

SaTokenException 的兜底处理放在最后。由于 NotLoginException 继承自 SaTokenException,Spring 会优先匹配最具体的类型——NotLoginException 会先被第一个方法捕获,只有其他 SaTokenException 子类才会落到兜底方法。


4.3. 验证异常处理效果

重启项目,通过三个测试场景验证全局异常处理器是否按预期工作。

场景一:不携带任何 Token(场景值 -1)

1
GET http://localhost:8081/auth/info

预期响应:

1
2
3
4
5
{
"code": 401,
"msg": "未提供 Token,请先登录",
"data": null
}

场景二:携带一个随机伪造的 Token(场景值 -2)

1
2
GET http://localhost:8081/auth/info
Header: satoken: this-is-a-fake-token

预期响应:

1
2
3
4
5
{
"code": 401,
"msg": "Token 无效,请重新登录",
"data": null
}

场景三:正常登录后访问(应通过校验)

1
POST http://localhost:8081/auth/login?username=user&password=123456

拿到 Token 后:

1
2
GET http://localhost:8081/auth/info
Header: satoken: <刚才拿到的 Token>

预期响应正常返回会话信息,不触发任何异常。

场景值 -3 到 -6 的触发需要特定条件(Token 过期、被踢出、被顶替、被冻结),将在第五章下线行为的测试中逐一覆盖——届时全局异常处理器会把每一种原因转化为对应的差异化提示,直观感受场景值机制的价值。


4.4. 统一响应格式的约定

目前我们的接口全部使用 SaResult 作为响应体,这是 Sa-Token 内置的一个简单工具类:

1
2
3
4
5
6
7
// 常用静态工厂方法
SaResult.ok(); // { "code": 200, "msg": "ok", "data": null }
SaResult.ok("操作成功"); // { "code": 200, "msg": "操作成功", "data": null }
SaResult.ok("操作成功").setData(x); // { "code": 200, "msg": "操作成功", "data": x }
SaResult.data(x); // { "code": 200, "msg": "ok", "data": x }
SaResult.error("操作失败"); // { "code": 500, "msg": "操作失败", "data": null }
SaResult.error("操作失败").setCode(401); // { "code": 401, "msg": "操作失败", "data": null }

SaResult 结构简单,能满足演示需求,但它并不适合直接用于生产项目。实际项目中通常会自定义响应体类,追加业务状态码、时间戳、traceId 等字段,并通过 @RestControllerAdvice 配合 ResponseBodyAdvice 对所有响应做统一包装。这些属于 Spring MVC 的工程化实践范畴,与 Sa-Token 本身无关,本系列不展开,按项目实际情况处理即可。

本系列后续所有接口保持使用 SaResult,以便把注意力集中在 Sa-Token 的功能本身。

如果你的项目使用了自定义响应体(如 R<T>Result<T>),将 GlobalExceptionHandler 中的 SaResult.error(...).setCode(401) 替换为对应的构造方式即可,异常捕获逻辑本身完全不需要改动。


4.5. 本章总结

本章回顾

本章完成了认证异常处理体系的建设。我们首先系统梳理了 NotLoginException 的 7 种场景值,理解了为什么同样是"Token 校验失败",框架要区分出 7 个不同的原因——差异化的场景值是差异化用户提示的前提。在代码层面,创建了 GlobalExceptionHandler,通过 Java 17 的 switch 表达式将场景值映射为对应的中文提示,并统一返回 401 状态码。兜底的 SaTokenException 处理方法确保了任何未预期的框架异常都不会以 HTML 页面的形式暴露给前端。最后简要说明了 SaResult 在本系列中的定位,以及与实际项目自定义响应体的对接方式。有了这套机制,第五章的下线行为测试才能真正看到场景值的价值——每种下线方式对应的用户提示,将从这里"翻译"出来。

核心汇总表

场景值常量名触发条件对应提示示例
-1NOT_TOKEN请求未携带任何 Token“未提供 Token,请先登录”
-2INVALID_TOKENToken 无效或已注销“Token 无效,请重新登录”
-3TOKEN_TIMEOUTToken 超过绝对有效期“Token 已过期,请重新登录”
-4BE_REPLACED被同设备类型新登录顶替“您的账号已在其他设备登录”
-5KICK_OUT被管理员强制踢下线“您已被强制下线”
-6TOKEN_FREEZE超过活跃有效期被冻结“Token 已被冻结,请稍后重试”
-7NO_PREFIXToken 前缀格式不正确“Token 格式不正确,请重新登录”
异常类型处理优先级状态码说明
NotLoginException高(精确匹配)401按场景值区分 7 种原因
SaTokenException低(兜底匹配)500所有其他框架异常的兜底

第五章. 下线行为与多端踢人

阶段式学习路径

第四章为所有异常场景建好了"翻译机制"——NotLoginException 的 7 种场景值,通过全局异常处理器转化为差异化的 JSON 提示。但到目前为止,我们只验证了 -1(未携带 Token)和 -2(Token 无效)这两种最简单的情况。

剩下的 -4、-5 对应的是两种主动下线行为。本章我们来把这两种下线方式讲清楚,顺带把加上用户自己主动退出的 logout,完整覆盖 Sa-Token 的三种下线路径。有了第三章的 /auth/info 和第四章的全局异常处理器,每一种下线行为的效果都可以直接观测:先看在线终端列表,踢人,再用失效的 Token 访问接口,看看返回的是哪一条提示。


5.1. 三种下线方式的本质区别

先用一张表建立整体认知:

下线方式触发者Token 处理方式NotLoginException 场景值典型用户提示
logout用户自己主动退出直接清除 Token 记录-2(Token 已注销)“请重新登录”
kickout管理员或系统强制下线打上 kickout 标记,不清除-5“您已被强制下线”
replaced新登录自动顶替旧会话打上 replaced 标记,不清除-4“您的账号已在其他设备登录”

三者在 API 形态上非常相似,但产生的用户体验完全不同。这正是 Sa-Token 场景值机制的价值所在——同样是"这个 Token 用不了了",用户看到的提示可以根据原因完全不同,前端可以据此决定是跳转到登录页、弹出一个提示框,还是展示一个"账号异常"的专属页面。

logout 是用户视角的正常退出。Token 记录从 Redis 中彻底清除,之后携带这个 Token 访问任何接口,都会得到场景值 -2(INVALID_TOKEN)。

kickout 是管理员视角的强制下线。Sa-Token 不会删除 Token 记录,而是在 Redis 中给这个 Token 打上一个特殊标记。用户再次携带该 Token 访问接口时,框架识别到标记,抛出场景值 -5(KICK_OUT)的异常——这样前端就能区分"Token 过期了"和"被管理员踢了"两种情况。

replaced 不需要手动调用——它是第一章"同端互斥"机制的自动产物。当同一账号以相同设备类型再次登录时,框架给旧 Token 打上 replaced 标记。旧 Token 的持有者下次访问,会收到场景值 -4(BE_REPLACED),提示"您的账号已在其他设备登录"。


5.2. logout:用户主动退出

logout 提供了三种粒度,从细到粗分别是:注销当前 Token、注销指定账号的某个设备端、注销指定账号的所有会话。

1
2
3
4
5
6
7
8
9
10
11
// 注销当前请求携带的 Token(最常用,用于"退出登录"按钮)
StpUtil.logout();

// 根据 Token 值精确注销某一个会话
StpUtil.logoutByTokenValue("xxxx-xxxx-xxxx-xxxx");

// 注销指定账号在指定设备类型上的所有会话
StpUtil.logout(10001L, "PC");

// 注销指定账号在所有设备上的全部会话(强制全端下线)
StpUtil.logout(10001L);

我们已经在第一章的登录接口中提供了 POST /auth/logout,它调用的正是第一个无参版本,注销当前请求携带的 Token。用户点击"退出登录"时调用此接口,Token 随即失效。


5.3. kickout:强制踢人下线

kickout 的 API 形态与 logout 完全对称,区别仅在于处理方式——打标记而非删除:

1
2
3
4
5
6
7
8
// 将指定账号的所有会话踢下线(全端)
StpUtil.kickout(10001L);

// 将指定账号在指定设备类型上的会话踢下线
StpUtil.kickout(10001L, "PC");

// 根据 Token 值将对应会话踢下线
StpUtil.kickoutByTokenValue("xxxx-xxxx-xxxx-xxxx");

kickout 是典型的管理员操作,职责上不应该与用户自己的登录注销混在同一个 Controller 里。下一节我们创建独立的 AdminController 来承载它。


5.4. 创建管理员控制器

踢人下线是管理后台的操作,我们将其放在独立的 AdminController 中,接口设计遵循 RESTful 风格——踢人的本质是"删除一个会话资源",所以使用 DELETE 方法。

📄 src/main/java/com/example/authsatoken/controller/AdminController.java(新建)

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
package com.example.authsatoken.controller;

import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* 管理员会话管理控制器
* 提供查询在线终端、踢人下线等管理操作
*
* 注意:实际项目中这些接口应加上管理员身份校验(第三篇讲解),这里先聚焦踢人逻辑本身
*/
@RestController
@RequestMapping("/admin")
public class AdminController {

/**
* 查询指定账号当前所有活跃会话的 Token 列表
* 管理后台使用,不需要持有被查账号的 Token,只需要 userId
*/
@GetMapping("/users/{userId}/sessions")
public SaResult getSessionList(@PathVariable Long userId) {
List<String> tokenList = StpUtil.getTokenValueListByLoginId(userId);
return SaResult.ok().setData(tokenList);
}

/**
* 踢掉指定账号的所有会话(全端踢出)
* 场景:账号异常、违规封号等需要立即全端下线的情况
*/
@DeleteMapping("/users/{userId}/sessions")
public SaResult kickoutAll(@PathVariable Long userId) {
StpUtil.kickout(userId);
return SaResult.ok("已将账号 " + userId + " 的所有设备踢下线");
}

/**
* 踢掉指定账号在指定设备类型上的会话
* 场景:用户举报某个设备异常登录,管理员定向下线可疑设备
*/
@DeleteMapping("/users/{userId}/sessions/{device}")
public SaResult kickoutDevice(@PathVariable Long userId,
@PathVariable String device) {
StpUtil.kickout(userId, device);
return SaResult.ok("已将账号 " + userId + " 在 " + device + " 端踢下线");
}

/**
* 根据 Token 值踢掉对应的会话
* 场景:用户在"账号安全"页看到陌生登录记录,精确下线某个可疑 Token
*/
@DeleteMapping("/sessions/{token}")
public SaResult kickoutByToken(@PathVariable String token) {
StpUtil.kickoutByTokenValue(token);
return SaResult.ok("已将 Token 对应的会话踢下线");
}
}

三个接口覆盖了三种踢人粒度,从粗到细:按账号全端踢出 → 按账号加设备类型定向踢出 → 按 Token 值精确踢出。资源路径的嵌套结构 /admin/users/{userId}/sessions/{device} 语义清晰——“操作某个用户在某个设备上的会话”。


5.5. 全流程验证

现在重启项目,通过两个完整的测试序列,把 kickoutreplaced 对应的场景值验证一遍。有了第四章的全局异常处理器,每种下线原因都会翻译成可读的 JSON 提示,而不是一堆 HTML 乱码。

验证 kickout(场景值 -5)

步骤 1:user 账号登录,记录 Token

1
POST http://localhost:8081/auth/login?username=user&password=123456

从响应 data 字段拿到 Token 值,命名为 Token-U。

步骤 2:确认账号在线,查看当前会话信息

1
2
GET http://localhost:8081/auth/info
Header: satoken: <Token-U>

预期响应中 onlineCount1tokenList 包含 Token-U。

步骤 3:管理员将 user 账号(userId=10002)全端踢下线

1
DELETE http://localhost:8081/admin/users/10002/sessions

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "已将账号 10002 的所有设备踢下线",
"data": null
}

步骤 4:用 Token-U 再次访问 /auth/info

1
2
GET http://localhost:8081/auth/info
Header: satoken: <Token-U>

预期响应:

1
2
3
4
5
{
"code": 401,
"msg": "您已被强制下线",
"data": null
}

场景值 -5 被全局异常处理器翻译成了"您已被强制下线",与第四章的映射完全对应。


验证 replaced(场景值 -4)

replaced 不需要手动调用 API,它是同端互斥的自动产物。第一章我们已经理解了这个机制,这里通过完整测试序列把场景值 -4 跑出来。

步骤 5:user 账号以 PC 端登录,记录 Token-P1

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=PC

步骤 6:同账号再次以 PC 端登录(不同设备类型不触发顶替)

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=PC

步骤 7:用旧的 Token-P1 访问 /auth/info

1
2
GET http://localhost:8081/auth/info
Header: satoken: <Token-P1>

预期响应:

1
2
3
4
5
{
"code": 401,
"msg": "您的账号已在其他设备登录,当前会话已下线",
"data": null
}

场景值 -4 触发——同设备类型的新登录自动将旧 Token 标记为"已被顶替"。

步骤 8:确认 APP 端不受影响

在步骤 5 之前,如果同一账号已经有一个 APP 端的 Token,可以携带它访问 /auth/info——APP 端的会话完全不受 PC 端重新登录的影响,仍然正常返回会话信息。这再次印证了第一章的结论:同端互斥,不同端共存

在验证过程中有一个细节值得停下来思考:logoutkickout 对用户来说体验相似(都是"用不了这个 Token 了"),但 Redis 中的数据状态是不同的。

logout 之后,Redis 中这个 Token 对应的键值被删除,携带它访问接口得到场景值 -2(INVALID_TOKEN)——框架在 Redis 里找不到这个 Token,判断它从未存在或已被清除。

kickout 之后,Redis 中 Token 对应的记录依然存在,但附加了一个 kickout 标记。框架能找到这个 Token,知道它的主人是谁,同时也知道它已被强制下线,于是抛出场景值 -5(KICK_OUT)而非 -2。

这个差异在大多数业务场景下不影响使用,但在某些需要"区分用户是主动退出还是被踢出"的审计日志场景中,场景值的不同会直接决定日志记录的内容。


5.7. 本章总结

本章回顾

本章完成了下线行为体系的完整建设。我们首先从概念层面区分了 logoutkickoutreplaced 三种下线方式在触发者、Token 处理方式和场景值上的本质差异,理解了场景值机制让"Token 失效"这件事有了可区分的原因。在代码层面,AdminController 提供了三种粒度的踢人接口:全端踢出、按设备类型定向踢出、按 Token 值精确踢出,接口设计遵循 RESTful 的 DELETE 语义和资源嵌套路径。最后通过两个独立测试序列,分别将 kickout(场景值 -5)和 replaced(场景值 -4)跑出来,配合第四章的全局异常处理器,验证了每条差异化提示的准确触发。

核心汇总表

下线方式触发者Token 处理场景值用户侧提示
logout用户自己从 Redis 删除-2“Token 无效,请重新登录”
kickout管理员 / 系统打 kickout 标记-5“您已被强制下线”
replaced新登录自动触发打 replaced 标记-4“您的账号已在其他设备登录”
API粒度说明
StpUtil.logout()当前 Token注销当前请求的会话
StpUtil.logout(id)指定账号全端注销指定账号所有设备
StpUtil.logout(id, device)指定账号指定设备注销指定设备类型的会话
StpUtil.logoutByTokenValue(token)指定 Token精确注销某一会话
StpUtil.kickout(id)指定账号全端强制踢出指定账号所有设备
StpUtil.kickout(id, device)指定账号指定设备定向踢出指定设备类型
StpUtil.kickoutByTokenValue(token)指定 Token精确踢出某一会话
接口HTTP 方法踢人粒度
/admin/users/{userId}/sessionsGET查询指定账号在线终端列表
/admin/users/{userId}/sessionsDELETE踢出指定账号全端
/admin/users/{userId}/sessions/{device}DELETE踢出指定账号指定设备
/admin/sessions/{token}DELETE踢出指定 Token

第六章. 账号封禁

阶段式学习路径

第五章讲的是下线——把已登录的人"踢出去"。但踢出去只解决了当下的问题,违规账号退出之后,下一秒就可以重新登录进来。

账号封禁解决的是"进不来"的问题:在封禁期间,无论该账号用什么设备、什么时间尝试登录,都应该被拒之门外。本章从最基础的整体封禁出发,逐步延伸到只限制部分能力的分类封禁、按违规程度递进的阶梯封禁,最后处理封禁数据在缓存重启后的持久化问题。


6.1. 基础封禁

对指定账号发起封禁:

1
2
// 封禁指定账号,封禁时长 86400 秒(1 天)
StpUtil.disable(10001L, 86400);

两个参数的含义:

  • 参数 1:要封禁的账号 id。
  • 参数 2:封禁时长,单位秒,传入 -1 代表永久封禁。

封禁后,在该账号下次尝试登录时校验一下封禁状态,通过校验才允许登录:

1
2
3
4
// 校验是否已被封禁,如果是则抛出异常 DisableServiceException
StpUtil.checkDisable(10001L);
// 通过校验后,再执行登录
StpUtil.login(10001L);

v1.31.0 之后,StpUtil.login() 不再自动校验账号是否被封禁,需要开发者在登录逻辑中手动调用 checkDisable() 进行前置校验。

在我们现有的登录接口中,加入封禁校验:

📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改 login 方法,在执行登录前追加封禁校验)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 3. 校验该账号是否已被封禁
StpUtil.checkDisable(userId);

// 4. 根据角色构建差异化的登录参数(原有逻辑不变)
boolean isAdmin = "admin".equals(username);
SaLoginParameter loginParam = new SaLoginParameter()
.setDeviceType(device)
.setIsConcurrent(!isAdmin)
.setMaxLoginCount(isAdmin ? 1 : 2)
.setIsLastingCookie(rememberMe);

StpUtil.login(userId, loginParam);
return SaResult.ok("登录成功").setData(StpUtil.getTokenValue());

此模块的全部可用 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 封禁指定账号
StpUtil.disable(10001L, 86400);

// 查询指定账号是否已被封禁(true=已封禁,false=未封禁)
StpUtil.isDisable(10001L);

// 校验指定账号是否已被封禁,如果是则抛出 DisableServiceException
StpUtil.checkDisable(10001L);

// 获取指定账号剩余封禁时间,单位:秒(-1=永久封禁,-2=未被封禁)
StpUtil.getDisableTime(10001L);

// 解除封禁
StpUtil.untieDisable(10001L);

踢 + 封的组合策略

对于当前正在线的违规账号,单纯封禁并不会让它立刻掉线——封禁只影响下次登录,已有的会话仍然有效。如果需要立即生效,采用先踢后封的组合:

1
2
3
4
// 先将该账号所有在线会话踢下线
StpUtil.kickout(10001L);
// 再封禁账号,阻止其重新登录
StpUtil.disable(10001L, 86400);

6.2. 分类封禁

有时候我们并不需要封禁整个账号,而是只限制其访问部分服务。

假设我们在开发一个电商系统,对于违规账号设定三种处罚:

  • 账号 A 因多次虚假好评,封禁其 评论能力
  • 账号 B 因多次薅羊毛,封禁其 下单能力
  • 账号 C 因店铺销售假货,封禁其 开店能力

三项处罚相互独立,封禁评论不影响下单,封禁下单不影响浏览。这就需要分类封禁:

1
2
// 封禁指定用户的评论能力,期限 1 天
StpUtil.disable(10001L, "comment", 86400);

三个参数的含义:

  • 参数 1:要封禁的账号 id。
  • 参数 2:业务标识(可以是任意自定义字符串)。
  • 参数 3:封禁时长,单位秒,-1 代表永久封禁。

在对应的业务接口中进行校验:

1
2
3
4
5
6
7
// 在评论接口校验评论能力,已被封禁则抛出 DisableServiceException
// 可通过 e.getService() 拿到业务标识 "comment"
StpUtil.checkDisable(10001L, "comment");

// 在下单接口校验下单能力
// 不会抛出异常,因为我们没有封禁其下单能力
StpUtil.checkDisable(10001L, "place-order");

分类封禁的完整 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 封禁:指定账号的指定服务
StpUtil.disable(10001L, "comment", 86400);

// 判断:指定账号的指定服务是否已被封禁
StpUtil.isDisable(10001L, "comment");

// 校验:指定账号的指定服务是否已被封禁,如果是则抛出异常
StpUtil.checkDisable(10001L, "comment");

// 获取:指定账号的指定服务剩余封禁时间(-1=永久,-2=未被封禁)
StpUtil.getDisableTime(10001L, "comment");

// 解封:指定账号的指定服务
StpUtil.untieDisable(10001L, "comment");

6.3. 阶梯封禁

对于多次违规的用户,处罚力度往往需要递进。以一个论坛系统为例,设定三种处罚力度:

  • 1 级:封禁发帖、评论能力,但允许点赞、关注。
  • 2 级:封禁一切互动能力,但允许浏览。
  • 3 级:封禁登录,限制一切能力。

关键在于把处罚力度量化为数字等级,数字越大代表封禁越严。然后使用阶梯封禁 API:

1
2
3
4
5
6
7
8
9
10
11
12
// 将账号 10001 封禁到 3 级,时长 10000 秒
StpUtil.disableLevel(10001L, 3, 10000);

// 获取指定账号当前封禁级别(未被封禁则返回 -2)
StpUtil.getDisableLevel(10001L);

// 判断指定账号是否已被封禁到指定级别,返回 true 或 false
StpUtil.isDisableLevel(10001L, 3);

// 校验:如果该账号已被封禁到 2 级及以上,则抛出异常
// 触发规则:实际封禁级别 >= 校验级别时抛出异常
StpUtil.checkDisableLevel(10001L, 2);

DisableServiceException 异常被抛出时,可以从中取出两个关键值:

1
2
3
4
5
6
try {
StpUtil.checkDisableLevel(10001L, 2);
} catch (DisableServiceException e) {
e.getLevel(); // 该账号实际被封禁的等级
e.getLimitLevel(); // 校验时要求的等级(即 checkDisableLevel 传入的值)
}

当实际封禁等级 >= limitLevel 时,框架就会抛出异常。也就是说,账号被封禁到 3 级时,对 2 级和 3 级的校验都会不通过;对 4 级的校验则依然通过。

分类封禁 + 阶梯封禁的组合

如果业务足够复杂,还可以将两者结合——对某个特定服务按等级封禁:

1
2
3
4
5
6
7
8
9
10
11
// 对账号 10001 的 comment 服务执行 3 级封禁,时长 10000 秒
StpUtil.disableLevel(10001L, "comment", 3, 10000);

// 获取该账号 comment 服务的封禁级别
StpUtil.getDisableLevel(10001L, "comment");

// 判断该账号 comment 服务是否已被封禁到 3 级
StpUtil.isDisableLevel(10001L, "comment", 3);

// 校验该账号 comment 服务,封禁等级是否达到 2 级
StpUtil.checkDisableLevel(10001L, "comment", 2);

6.4. 封禁信息持久化

Sa-Token 默认将封禁信息存储在缓存(Redis)中。缓存数据是"临时性的"——一旦 Redis 重启,封禁记录就会丢失,已被封禁的账号便能重新登录。对于大多数生产系统,封禁数据需要持久化到数据库。

最直接的做法是在调用 Sa-Token 封禁 API 之后,同步写入数据库:

1
2
3
4
// 在 Sa-Token 中封禁账号
StpUtil.disable(10001L, 86400);
// 同步写入数据库(示例代码,按实际业务实现)
userMapper.disableUser(10001L, 86400);

这样可以保证封禁数据同时存在于缓存和数据库中。但还有一个问题没解决:如果缓存中间件重启,缓存数据丢失,此时 StpUtil.checkDisable() 将失去约束效果,被封禁的账号可以顺利登录,直到缓存数据被重新同步。

Sa-Token 提供了一种更优雅的方案:实现 StpInterfaceisDisabled 方法。框架在每次调用 checkDisable() 时,会先查询缓存,缓存未命中时再调用你实现的 isDisabled 方法,从数据库中实时查询封禁状态。这样即使缓存丢失,封禁校验依然有效,且不需要在程序启动时批量同步数据。

StpInterface 将在第三篇讲解权限体系时正式引入。这里只需要知道它是 Sa-Token 的数据源扩展点,isDisabled 是其中负责封禁查询的方法。

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
@Component
public class StpInterfaceImpl implements StpInterface {

/**
* 返回指定账号是否被封禁
* 框架在 checkDisable() 缓存未命中时调用此方法,从数据库中查询真实封禁状态
*
* @param loginId 账号 id
* @param service 业务标识(无分类封禁时为默认值)
*/
@Override
public SaDisableWrapperInfo isDisabled(Object loginId, String service) {
// 查询数据库中的封禁记录(此处仅为示例代码)
DisableRecord record = userMapper.getDisableRecord(loginId, service);

if (record == null || record.isExpired()) {
// 未被封禁,且将查询结果缓存 3600 秒,期间不再重复查库
return SaDisableWrapperInfo.createNotDisabled(3600);
}
// 已被封禁,返回剩余封禁秒数和封禁等级
return SaDisableWrapperInfo.createDisabled(record.getRemainSeconds(), record.getLevel());
}

// getPermissionList 和 getRoleList 方法在第三篇实现,此处暂时返回空列表
@Override
public List<String> getPermissionList(Object loginId, String loginType) { return List.of(); }
@Override
public List<String> getRoleList(Object loginId, String loginType) { return List.of(); }
}

SaDisableWrapperInfo 的几种写法:

1
2
3
4
5
6
7
8
9
10
11
// 未被封禁
return SaDisableWrapperInfo.createNotDisabled();

// 未被封禁,且将结果缓存 3600 秒(期间不再进入 isDisabled 方法)
return SaDisableWrapperInfo.createNotDisabled(3600);

// 已被封禁,剩余 86400 秒,封禁等级为 1
return SaDisableWrapperInfo.createDisabled(86400, 1);

// 标准写法:new 对象,参数为(是否封禁、剩余秒数、封禁等级)
return new SaDisableWrapperInfo(true, 86400, 1);

6.5. 本章总结

本章回顾

本章从"踢出去"延伸到"进不来",完整讲解了 Sa-Token 的账号封禁体系。基础封禁通过 disable()checkDisable() 实现"登录前校验封禁状态"的标准流程,并说明了"先踢后封"的组合策略以确保已在线用户立即掉线。分类封禁引入了业务标识参数,使得不同能力(评论、下单、开店)可以独立封禁,互不干扰,适合细粒度的差异化处罚场景。阶梯封禁将处罚力度量化为等级数字,结合 checkDisableLevel() 的"等级 >= 阈值则拦截"规则,实现了处罚力度的递进管理,并可以与分类封禁组合使用。最后通过 StpInterface.isDisabled() 方法解决了缓存重启后封禁数据丢失的问题,使封禁校验始终能回源数据库。

核心汇总表

封禁类型封禁 API校验 API适用场景
基础封禁disable(id, time)checkDisable(id)封禁整个账号
分类封禁disable(id, service, time)checkDisable(id, service)只封禁特定能力
阶梯封禁disableLevel(id, level, time)checkDisableLevel(id, level)按等级递进处罚
分类+阶梯disableLevel(id, service, level, time)checkDisableLevel(id, service, level)特定能力按等级处罚
API说明
StpUtil.disable(id, time)封禁账号,-1 为永久
StpUtil.isDisable(id)是否已被封禁,返回 boolean
StpUtil.checkDisable(id)校验封禁状态,已封禁则抛 DisableServiceException
StpUtil.getDisableTime(id)剩余封禁秒数,-2 表示未封禁
StpUtil.untieDisable(id)解除封禁
e.getLevel()异常中获取实际封禁等级
e.getLimitLevel()异常中获取校验时传入的等级阈值
SaDisableWrapperInfo 写法含义
createNotDisabled()未被封禁
createNotDisabled(ttl)未被封禁,结果缓存 ttl 秒
createDisabled(seconds, level)已被封禁,剩余秒数和等级

第七章. 二级认证

阶段式学习路径

前几章解决的都是"你是谁"和"你能不能进来"的问题。但有些操作需要在已经登录的基础上,再多走一步验证——即使你已经证明了自己的身份,在执行某些高风险操作之前,系统还是需要你再次确认。

你一定用过这类体验:代码托管平台删除仓库时要求重新输入密码、银行转账时需要短信验证码、修改手机号时需要人脸识别。这就是本章要讲的二级认证——在已登录会话的基础上,对特定操作额外加一道验证门槛。


7.1. 核心概念

二级认证的本质是:在当前会话上打一个"已通过二级验证"的时效性标记。验证通过后,这个标记在指定时间内有效;一旦超时或主动关闭,标记消失,再次访问受保护的接口就需要重新验证。

这与 Session 或 Token 本身的有效期是完全独立的两个维度——你的登录状态可以是 30 天有效,但二级认证标记可能只有 120 秒有效。两者互不影响。


7.2. 核心 API

Sa-Token 二级认证的全部操作只有五个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 在当前会话开启二级认证,有效期 120 秒
StpUtil.openSafe(120);

// 查询当前会话是否处于二级认证有效期内,返回 true / false
StpUtil.isSafe();

// 校验当前会话是否已通过二级认证,未通过则直接抛出异常
StpUtil.checkSafe();

// 获取当前会话二级认证的剩余有效时间,单位:秒
// 返回 -2 代表尚未开启或已过期
StpUtil.getSafeTime();

// 主动关闭当前会话的二级认证
StpUtil.closeSafe();

openSafe()closeSafe() 是一对开关,isSafe()checkSafe() 的关系与第三章中 isLogin()checkLogin() 完全类似——前者是查询,返回布尔值,不抛异常;后者是校验,不通过直接中断请求。


7.3. 一个完整的业务示例

以"删除仓库"这个高风险操作为例,演示二级认证的完整业务流程:

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
/**
* 删除仓库接口(高风险操作,需要二级认证)
*/
@RequestMapping("/deleteProject")
public SaResult deleteProject(String projectId) {
// 第 1 步:检查当前会话是否已完成二级认证
if (!StpUtil.isSafe()) {
return SaResult.error("操作失败,请先完成安全验证");
}
// 第 2 步:二级认证通过,执行删除业务逻辑
// projectService.delete(projectId);
return SaResult.ok("仓库删除成功");
}

/**
* 发起二级认证(用户输入密码后调用此接口)
*/
@RequestMapping("/openSafe")
public SaResult openSafe(String password) {
// 比对密码(实际项目中应从数据库查询并对比哈希值,这里简化演示)
if (!"123456".equals(password)) {
return SaResult.error("密码错误,安全验证失败");
}
// 比对成功,为当前会话打开二级认证,有效期 120 秒
StpUtil.openSafe(120);
return SaResult.ok("安全验证成功,请在 120 秒内完成操作");
}

完整的调用链路是这样的:

  1. 前端调用 deleteProject 接口,尝试删除仓库。
  2. 后端发现当前会话尚未通过二级认证,返回"请先完成安全验证"。
  3. 前端弹出密码输入框,用户输入密码,调用 openSafe 接口。
  4. 后端比对密码通过,为当前会话打开二级认证标记,有效期 120 秒。
  5. 前端在 120 秒内再次调用 deleteProject 接口。
  6. 后端检测到二级认证有效,执行删除操作,返回成功。

7.4. 指定业务标识

如果项目有多条业务线都需要二级认证,默认的 openSafe() 无法区分"这次验证通过的是哪个操作"。举个例子:用户为了删除仓库完成了密码验证,结果这个验证状态同时也让他能免验证地绑定新手机号——这显然不是我们想要的。

Sa-Token 通过业务标识解决这个问题。不同业务标识的二级认证彼此完全独立,互不干扰:

1
2
3
4
5
6
7
8
9
10
11
// 为"删除仓库"操作开启二级认证,有效期 120 秒
StpUtil.openSafe("delete-project", 120);

// 为"绑定手机号"操作开启二级认证,有效期 300 秒
StpUtil.openSafe("bind-phone", 300);

// 校验当前会话是否已通过"删除仓库"的二级认证
StpUtil.checkSafe("delete-project");

// 校验"绑定手机号"的二级认证——不会通过,因为用户只验证了"删除仓库"
StpUtil.checkSafe("bind-phone");

业务标识可以是任意字符串,由你的业务语义决定。建议使用具有描述性的短语,方便代码阅读和日志排查。

带业务标识的完整 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 开启指定业务的二级认证
StpUtil.openSafe("client", 600);

// 查询是否已通过指定业务的二级认证
StpUtil.isSafe("client");

// 校验指定业务的二级认证,未通过则抛出异常
StpUtil.checkSafe("client");

// 获取指定业务二级认证的剩余有效时间(-2=未开启或已过期)
StpUtil.getSafeTime("client");

// 关闭指定业务的二级认证
StpUtil.closeSafe("client");

7.5. 用注解简化校验

如果你不想在每个高风险接口的方法体内写 checkSafe() 的判断逻辑,可以改用注解方式,由拦截器在进入方法之前自动完成校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 必须通过默认二级认证才能访问此接口
@SaCheckSafe
@RequestMapping("/deleteProject")
public SaResult deleteProject(String projectId) {
// 执行删除操作,二级认证校验已在方法外完成
return SaResult.ok("仓库删除成功");
}

// 必须通过指定业务标识的二级认证才能访问此接口
@SaCheckSafe("delete-project")
@RequestMapping("/deleteProject2")
public SaResult deleteProject2(String projectId) {
return SaResult.ok("仓库删除成功");
}

@SaCheckSafe 注解需要 SaInterceptor 拦截器生效才能工作。该拦截器将在第三篇正式引入,目前如果需要使用注解方式,可以先参考第三篇的配置提前注册。

未通过二级认证时,@SaCheckSafe 会抛出 NotSafeException,建议在 GlobalExceptionHandler 中追加对应的处理:

📄 src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(追加方法)

1
2
3
4
5
6
7
8
9
10
import cn.dev33.satoken.exception.NotSafeException;

/**
* 捕获二级认证校验失败异常
* 触发场景:访问需要二级认证的接口,但当前会话尚未完成或已过期
*/
@ExceptionHandler(NotSafeException.class)
public SaResult handleNotSafeException(NotSafeException e) {
return SaResult.error("当前操作需要二级认证,请先完成安全验证").setCode(401);
}

7.6. 本章总结

本章回顾

本章完整讲解了二级认证机制。二级认证是在已登录会话基础上叠加的额外验证层,与 Token 有效期相互独立,专门用于保护高风险操作。核心 API 只有五个:openSafe()isSafe()checkSafe()getSafeTime()closeSafe()isSafe()checkSafe() 的语义分工与第三章的 isLogin() / checkLogin() 完全对应。通过业务标识参数,不同操作的二级认证状态彼此隔离,解决了"验证了 A 操作却能跳过 B 操作验证"的安全漏洞。@SaCheckSafe 注解提供了声明式的校验写法,并在 GlobalExceptionHandler 中追加了对应的异常处理。

核心汇总表

API说明
StpUtil.openSafe(time)开启二级认证,有效期 time 秒
StpUtil.openSafe(service, time)开启指定业务的二级认证
StpUtil.isSafe()查询是否已通过二级认证,返回 boolean
StpUtil.isSafe(service)查询指定业务的二级认证状态
StpUtil.checkSafe()校验二级认证,未通过抛 NotSafeException
StpUtil.checkSafe(service)校验指定业务的二级认证
StpUtil.getSafeTime()获取剩余有效秒数,-2=未开启或已过期
StpUtil.getSafeTime(service)获取指定业务剩余有效秒数
StpUtil.closeSafe()主动关闭二级认证
StpUtil.closeSafe(service)主动关闭指定业务的二级认证
isSafe() vs checkSafe()适用场景
isSafe()未通过时需要返回自定义响应体,不希望抛异常
checkSafe()未通过时直接中断请求,配合全局异常处理器统一响应
业务标识使用建议示例
单一高风险操作"delete-project" / "bind-phone"
多个操作共享一次验证不传业务标识,使用默认标记
不同操作需独立验证每个操作使用不同的业务标识字符串

第八章. 模拟他人 / 临时身份切换

阶段式学习路径

前七章的所有操作都围绕同一个主体:当前请求者自己。登录、查询会话信息、下线、封禁、二级认证,操作对象要么是自己的 Token,要么是管理员针对他人的会话发起的外部操作。

但有一类场景,需要"以自己的权限,站在别人的视角执行操作"。比如:客服系统中,客服人员需要临时切换到用户视角排查问题;多租户系统中,超级管理员需要进入某个租户的上下文查看数据。这就是本章要讲的——模拟他人与临时身份切换。


8.1. 操作指定账号的 API

Sa-Token 的大部分 StpUtil 方法默认操作的是"当前请求携带的 Token 对应的账号"。但很多方法也提供了带 loginId 参数的重载版本,允许直接操作任意指定账号,而不需要持有对方的 Token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取指定账号当前的 Token 值(账号有多个在线设备时返回最新一个)
StpUtil.getTokenValueByLoginId(10001L);

// 获取指定账号当前所有在线 Token 的列表
StpUtil.getTokenValueListByLoginId(10001L);

// 强制将指定账号的所有会话注销下线
StpUtil.logout(10001L);

// 获取指定账号的 Account-Session 对象,不存在则新建并返回
StpUtil.getSessionByLoginId(10001L);

// 获取指定账号的 Account-Session 对象,不存在则返回 null
StpUtil.getSessionByLoginId(10001L, false);

这些方法在第五章的 AdminController 中已经用过——管理员踢人不需要持有被踢者的 Token,只需要知道 userId 即可。权限相关的跨账号查询将在第三篇引入,这里先建立"可以直接操作任意账号"的基本认知。


8.2. 临时身份切换

有时候,我们不是要"操作"某个账号,而是需要 在当前请求内,临时变成另一个账号

典型场景:客服系统中,客服人员(userId=9001)已登录,但需要以用户(userId=10002)的视角调用一系列查询接口,看看该用户能看到什么、能操作什么——此时不可能让客服真的注销自己重新用用户账号登录,也不应该修改客服自己的 Token 记录。

Sa-Token 为此提供了 switchTo()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 将当前会话的身份临时切换为 userId=10044,本次请求内有效
StpUtil.switchTo(10044L);

// 此时调用 getLoginId(),返回的是切换后的 10044,而不是实际登录者
StpUtil.getLoginId(); // 返回 10044

// 查询是否正处于临时身份切换状态
StpUtil.isSwitch(); // 返回 true

// 结束临时身份切换,恢复为实际登录者的身份
StpUtil.endSwitch();

// 切换结束后,getLoginId() 重新返回实际登录者的 id
StpUtil.getLoginId(); // 返回实际登录者的 id

switchTo() 只影响当前请求线程内对 getLoginId() 等方法的返回值,不会修改任何 Redis 数据,不会创建新的 Token,请求结束后自动失效。


8.3. Lambda 写法:无需手动关闭

使用 switchTo() + endSwitch() 的写法有一个隐患:如果在切换期间代码抛出异常,endSwitch() 可能不会被执行,导致当前线程的身份状态残留。

Sa-Token 提供了 Lambda 版本,将切换逻辑包裹在一个闭包内,执行完成后(无论是否抛出异常)自动恢复:

1
2
3
4
5
6
7
8
9
10
11
System.out.println("切换前 loginId:" + StpUtil.getLoginId());

StpUtil.switchTo(10044L, () -> {
// 在这个代码块内,当前身份是 10044
System.out.println("切换中,isSwitch:" + StpUtil.isSwitch()); // true
System.out.println("切换中,loginId:" + StpUtil.getLoginId()); // 10044
// 在这里执行需要以 10044 身份进行的操作
});

// Lambda 执行完毕后,身份自动恢复,无需手动调用 endSwitch()
System.out.println("切换后 loginId:" + StpUtil.getLoginId()); // 恢复为实际登录者

Lambda 写法不仅更安全,语义也更清晰——临时身份的作用范围被显式地限定在花括号内,代码阅读者一眼就能看出哪些操作是"以别人的身份执行"的。实际项目中推荐优先使用 Lambda 写法。


8.4. 一个重要的边界:切换身份 ≠ 获得对方权限

临时身份切换容易让人产生一个误解:切换到 userId=10044 之后,是不是就拥有了 10044 的所有权限?

答案取决于你的权限数据是怎么查的。

switchTo() 改变的只是 StpUtil.getLoginId() 的返回值。如果你的权限查询逻辑是基于 getLoginId() 去查数据库或缓存的(比如第三篇将要实现的 StpInterface.getPermissionList()),那么切换身份后,权限查询确实会基于 10044 的 userId,返回 10044 的权限数据。

但如果你的权限校验走的是注解(@SaCheckPermission 等),它同样会用切换后的 loginId 去查权限,结果也是 10044 的权限范围。

总结成一句话:switchTo() 让框架认为"你就是那个人",所有基于 loginId 的查询都会跟着切换;但它不会绕过任何已有的权限校验逻辑。 如果切换到的账号权限不足,权限校验照样会失败。

临时身份切换是高权限操作,调用方本身通常需要具备管理员级别的权限。实际项目中应在接口层对调用方做身份验证,确保普通用户无法随意切换到他人身份。


8.5. 本章总结

本章回顾

本章讲解了两类"跨账号操作"的能力。第一类是操作指定账号的 API——通过传入 loginId 参数,直接对任意账号的会话、Session 进行操作,无需持有对方的 Token,这类 API 在前几章的管理员踢人场景中已有实际应用。第二类是临时身份切换——switchTo() 在当前请求线程内将 getLoginId() 的返回值替换为目标账号,配合 Lambda 写法可以将切换作用域显式限定在代码块内,执行完成后自动恢复,避免状态残留。最后澄清了一个常见误区:身份切换改变的是 loginId 的返回值,所有基于 loginId 的权限查询会跟着切换,但不会绕过任何权限校验逻辑,被切换到的账号权限不足时校验照样失败。

核心汇总表

API说明
StpUtil.getTokenValueByLoginId(id)获取指定账号最新的 Token 值
StpUtil.getTokenValueListByLoginId(id)获取指定账号所有在线 Token 列表
StpUtil.logout(id)强制注销指定账号所有会话
StpUtil.getSessionByLoginId(id)获取指定账号的 Session,不存在则新建
StpUtil.getSessionByLoginId(id, false)获取指定账号的 Session,不存在返回 null
API说明
StpUtil.switchTo(id)临时切换当前身份为指定 loginId,本次请求内有效
StpUtil.switchTo(id, () -> {...})Lambda 写法,执行完毕后自动恢复,推荐使用
StpUtil.isSwitch()查询当前是否处于临时身份切换状态
StpUtil.endSwitch()手动结束临时身份切换(Lambda 写法无需调用)
使用场景推荐写法
临时切换并执行一段逻辑switchTo(id, () -> { ... })
需要精确控制切换时机switchTo(id) + endSwitch()(注意异常安全)
切换后是否拥有对方权限取决于权限查询是否基于 getLoginId(),不自动绕过校验