登录注册番外篇(二) - Sa-Token:会话全生命周期管理
登录注册番外篇(二) - Sa-Token:会话全生命周期管理
Prorise第一章. 多端登录策略:两个配置项决定一切
环境版本
| 组件 | 版本 |
|---|---|
| JDK | 17 |
| Spring Boot | 3.4.x |
| Sa-Token | 1.44.0 |
| Redis | 7.x |
阶段式学习路径
在番外篇(一)中,我们用 StpUtil.login(10001) 完成了最基础的登录,并通过 Redis 实验观察了会话数据的存储结构。但有一个关键问题一直没有回答:同一个账号能不能在多个设备上同时登录?如果能,上限是多少?如果不能,新登录是挤掉旧登录还是直接拒绝?
本章我们将深入 Sa-Token 的多端登录策略,理解两个配置项背后的组合逻辑,并把番外篇(一)中的登录接口升级为支持设备类型和差异化策略的完整版本。
在开始之前,需要先对 application.yml 做一处调整。番外篇(一)中我们配置的是 is-share: true,这会导致同一账号多次登录拿到同一个 Token,无法演示多设备独立会话的效果。将它改为 false:
📄 src/main/resources/application.yml(修改)
1 | sa-token: |
这一改动的效果是:同一账号在不同设备上登录时,每次都会拿到一个全新的 Token,旧 Token 不失效。我们在后面的测试中会直接看到这个变化。
1.1. 两个配置项决定登录行为
is-concurrent 和 is-share 是两个相互独立的维度,它们的组合决定了整个项目的多端登录策略。
is-concurrent 回答的问题是"允不允许同时在多个设备上登录"。设置为 true 时,同一账号可以在手机、电脑、平板上同时保持登录状态;设置为 false 时,新设备一登录,旧设备的会话立刻失效,同一时刻只有一个有效会话存在。
is-share 在 is-concurrent=true 的前提下才有意义,它回答的是"多个设备是否共用同一个 Token"。设置为 true 时,账号的多次登录会拿到同一个 Token 值(前提是之前的 Token 仍在有效期内);设置为 false 时,每次登录都生成一个全新的 Token,各设备的会话彼此独立。
两者组合,产生三种实际可用的登录模式:
| is-concurrent | is-share | 登录模式 | 适用场景 |
|---|---|---|---|
| true | true | 多端共享 Token | 对安全要求不高、希望减少 Token 数量的场景 |
| true | false | 多端独立 Token | 最常用,既允许多设备在线,又能独立管理各设备会话 |
| false | — | 单端登录 | 对安全敏感的场景,如金融账户 |
我们目前的配置是第二种模式——多端独立 Token,也是生产项目中最常见的选择。
在继续之前,先用一个 Redis 实验直接感受 is-share 前后的差异。
实验:观察 is-share 切换对 Redis 数据的影响
将 is-share 临时改回 true,用同一账号连续登录两次,然后查看 Redis:
1 | # 查找该账号下的所有 Token 关联键 |
你会看到列表中只有一条 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 | StpUtil.login(userId, new SaLoginParameter() |
以下是本章会用到的核心配置项说明:
| 配置方法 | 对应全局配置 | 说明 |
|---|---|---|
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 | package com.example.authsatoken.controller; |
有几个关键点值得注意。
USER_DB 用 Map.of() 模拟了一个内存数据库:admin 账号对应 userId 10001,user 账号对应 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 | GET http://localhost:8081/auth/isLogin |
此步骤验证的是 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 | sa-token: |
active-timeout 默认值为 -1(不启用)。只配置 timeout 是完全合法的用法,大多数简单项目也只用 timeout 就够了。
2.2. 两个维度的组合逻辑
单独理解两个配置项并不难,真正需要思考的是它们 叠加在一起时的行为。
当两个配置同时启用时,框架采用的是 双重门槛,任意一个触发则失效 的策略。也就是说,Token 必须同时满足"未超过绝对有效期"和"最近一次请求距今未超过活跃期",才会被认为是有效的。
这产生了几个经典的业务组合:
“7 天内免登录,但连续 30 分钟不操作自动退出”
1 | sa-token: |
用户登录后,只要每隔不超过 30 分钟发起一次请求,Token 就会一直续签;但无论续签多少次,7 天到期后 Token 必然失效,用户需要重新登录。这是大多数 ToC 产品的标准策略。
“永不过期,但长时间不用会掉线”
1 | sa-token: |
Token 没有固定的寿命,只要用户保持活跃就一直在线。适合内部工具、管理后台等对持久登录有要求、但也不希望长时间挂机占用会话资源的场景。
“固定 30 天有效,不论是否活跃”
1 | sa-token: |
最简单的策略,Token 在 30 天内始终有效,不受访问频率影响。适合移动端 App、"记住我"场景。
2.3. 手动操作 Token 有效期
除了依靠框架自动管理,Sa-Token 也暴露了一组 API,允许在代码中手动查询和修改 Token 的有效期。
查询剩余有效期
1 | // 获取当前 Token 的绝对有效期剩余秒数 |
手动续签
1 | // 续签当前 Token 的活跃有效期(重置 active-timeout 倒计时) |
在登录时指定本次的有效期
SaLoginParameter 也可以单独指定这一次登录的 Token 有效期,覆盖全局的 timeout 配置:
1 | // 指定此次登录的 Token 绝对有效期为 7 天 |
这个用法在第一章已经出现过。结合本章的知识,你可以理解它的完整含义:只影响这一次登录生成的 Token,项目中其他账号的登录行为不受影响。
2.4. 为 /auth/info 接口添加有效期信息
为了让后续章节的测试有一个统一的"查看当前会话状态"的入口,我们现在向 LoginController 中添加 /auth/info 接口,并在返回数据中包含 Token 的剩余有效期信息。
📄 src/main/java/com/example/authsatoken/controller/LoginController.java(追加方法)
在已有的 isLogin 方法下方追加:
1 | import cn.dev33.satoken.stp.SaTokenInfo; |
登录后访问此接口,响应大致如下:
1 | { |
timeout 会随着时间流逝单调递减,始终不重置;activeTimeout 则每次调用此接口后都会被重置回 1800,因为访问本身就是一次"活跃"行为。
2.5. [记住我] 模式
几乎所有带登录页面的产品都有这个按钮。勾选之后,即使关闭浏览器再重新打开,依然处于登录状态,不需要再次输入密码。
Sa-Token 通过 StpUtil.login() 的第二个参数支持这一功能:
1 | // 记住我:关闭浏览器后 Token 依然有效 |
这个功能的底层依赖浏览器 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 |
|
setIsLastingCookie(true) 等价于 StpUtil.login(id, true),两者最终效果相同,只是前者通过 SaLoginParameter 传入,可以和其他登录参数组合使用。
前后端分离模式下如何实现 [记住我]
Cookie 虽好,却无法在前后端分离环境下使用——App、小程序等客户端默认没有实现 Cookie 功能,无法依赖框架自动写入和读取。这种情况下,Token 的存储和生命周期需要由 前端自己管理。
在 PC 浏览器的前后端分离场景(如 Vue + Axios)下:
1 | // 勾选了 [记住我]:存入 localStorage,浏览器关闭后依然保留 |
两种环境的核心思路是一致的:持久存储 → 记住我;会话级存储 → 不记住我。只是从浏览器 Cookie 的两种模式,变成了前端存储 API 的两种选择。服务端的代码不需要做任何改动,改变的只是前端把 Token 存在哪里。
前后端分离模式下,Token 的过期仍然由服务端的 timeout 和 active-timeout 控制。前端的"持久存储"只是保证了 Token 字符串不会因为关闭应用而丢失,并不会延长 Token 本身的有效期。
2.6. 本章总结
本章回顾
本章系统梳理了 Token 从创建到失效的两个时间维度。timeout 是绝对有效期,从登录时开始倒计时,不受请求频率影响;active-timeout 是活跃有效期,每次请求都会重置,长时间不操作才触发失效。两者独立配置,可以组合出"固定寿命"“活跃续签”"活跃续签 + 绝对上限"等多种策略,覆盖不同安全需求的业务场景。在代码层面,我们补充了查询和手动续签有效期的 API,新增了 /auth/info 接口作为后续测试的会话状态查询入口。最后讲解了"记住我"功能的 Cookie 实现原理,以及在前后端分离环境中用前端存储 API 替代 Cookie 的完整方案。
核心汇总表
| 配置项 | 含义 | 续签行为 | 典型值 |
|---|---|---|---|
timeout | Token 绝对有效期 | 不续签,倒计时不可重置 | 2592000(30 天) |
active-timeout | Token 活跃有效期 | 每次请求自动重置 | 1800(30 分钟) |
-1 | 关闭该维度的检查 | — | 两个配置项均支持 |
| 常用 API | 说明 |
|---|---|
StpUtil.getTokenTimeout() | 查询当前 Token 绝对有效期剩余秒数 |
StpUtil.getTokenActiveTimeout() | 查询当前 Token 活跃有效期剩余秒数 |
StpUtil.updateLastActiveToNow() | 手动续签活跃有效期 |
StpUtil.renewTimeout(sec) | 将绝对有效期续签为指定秒数 |
| [记住我] 实现方式 | 持久登录 | 非持久登录 |
|---|---|---|
| 传统 Cookie 模式 | setIsLastingCookie(true) | setIsLastingCookie(false) |
| uni-app | uni.setStorageSync() | getApp().globalData |
| PC 浏览器前后端分离 | localStorage | sessionStorage |
第三章. 读取当前会话信息
阶段式学习路径
前两章解决了"怎么登录"和"Token 活多久"的问题。但登录成功之后,还有一个同样高频的需求没有覆盖:如何从当前请求中取出"我是谁"?
每一个需要登录的接口,几乎都要做同一件事——先拿到当前用户的 ID,再用这个 ID 去查业务数据。除此之外,你可能还需要知道当前 Token 的完整信息、这个账号在所有设备上的在线情况,甚至只是判断一下"这个请求到底有没有登录"。
本章系统梳理 Sa-Token 为此提供的全部 API,并完善 /auth/info 接口,让它成为后续章节测试中真正好用的会话状态查询入口。
3.1. 获取当前登录用户 ID
StpUtil.getLoginId() 是使用频率最高的 API,没有之一。它的返回值是 Object 类型,因为 Sa-Token 内部将 loginId 统一以字符串形式存储(我们在番外篇一中通过 Redis 实验验证过这一点),所以直接拿到的是 String。
为了让调用方不必每次手动转型,Sa-Token 提供了几个带类型的重载方法:
1 | // 返回 Object 类型,实际是 String |
实际项目中,如果你的 userId 是数据库自增长整型,直接用 getLoginIdAsLong() 最方便;如果是 UUID 字符串,用 getLoginIdAsString()。
这几个方法在未登录时都会直接抛出 NotLoginException,而不是返回 null。如果你在一个不确定是否已登录的场景下调用它们,需要先用 StpUtil.isLogin() 判断,或者用 try-catch 捕获异常。
如果你需要取的不是当前请求者的 ID,而是某个 Token 对应的 loginId(比如管理后台根据 Token 反查账号),可以使用:
1 | // 根据 Token 值反查其对应的 loginId,Token 无效时返回 null |
3.2. 获取 Token 信息
StpUtil.getTokenValue() 返回当前请求携带的 Token 字符串,是最直接的方式:
1 | // 获取当前请求的 Token 字符串 |
如果你需要的不只是 Token 值,而是这个 Token 的完整元数据,使用 getTokenInfo():
1 | SaTokenInfo tokenInfo = StpUtil.getTokenInfo(); |
SaTokenInfo 包含以下字段:
1 | { |
大多数情况下,你不需要把所有字段都返回给前端,按需取用即可。
3.3. 查询登录状态
Sa-Token 提供了两个语义相近但行为截然不同的方法:
1 | // 查询:当前请求是否已登录,返回 true / false,不抛异常 |
两者的核心区别在于:isLogin() 是查询,只告诉你结果;checkLogin() 是校验,不满足条件就中断请求。
什么时候用 isLogin()?
适合那些"登录与否都能访问,但登录后返回更多数据"的场景:
1 |
|
什么时候用 checkLogin()?
适合那些"没有登录就没有任何意义"的接口——直接校验,不满足就抛异常,配合第四章的全局异常处理器自动返回 401:
1 |
|
注解鉴权(将在第三篇讲解)的 @SaCheckLogin 本质上就是在方法入口自动调用了 checkLogin(),两者效果完全等价,只是表达方式不同。
3.4. 查询一个账号的所有在线终端
第一章的多端登录场景中,我们用 user 账号同时在 PC 和 APP 上登录,产生了两个独立的 Token。如果想知道这个账号当前一共有几个设备在线,以及每个设备对应的 Token 是什么,使用:
1 | // 获取指定 loginId 当前所有有效 Token 的列表 |
返回的列表中,每一个字符串就是一个在线设备的 Token 值。列表为空表示该账号当前没有任何在线会话。
配合 getLoginDevice() 可以进一步查询每个 Token 对应的设备类型:
1 | // 获取当前请求 Token 登录时声明的设备类型 |
这两个 API 组合起来,就是"账号安全"页面中展示"当前登录设备列表"功能的数据来源。
3.5. 完善 /auth/info 接口
上一章我们添加了 /auth/info 接口的雏形,只返回了几个基本字段。现在结合本章的知识,将它完善为包含完整会话信息的版本。
📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改 info 方法)
1 | import cn.dev33.satoken.stp.SaTokenInfo; |
登录后访问此接口,响应大致如下:
1 | { |
onlineCount 为 2 说明这个账号当前有两个设备在线,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() | SaTokenInfo | Token 完整元数据(有效期、设备类型等) |
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 种:
| 场景值 | 常量名 | 触发条件 |
|---|---|---|
| -1 | NOT_TOKEN | 请求中完全没有携带 Token |
| -2 | INVALID_TOKEN | 携带了 Token,但在 Redis 中找不到(已注销或从未存在) |
| -3 | TOKEN_TIMEOUT | Token 存在,但已超过 timeout 配置的有效期 |
| -4 | BE_REPLACED | Token 被同设备类型的新登录顶替 |
| -5 | KICK_OUT | Token 被管理员通过 kickout 强制踢下线 |
| -6 | TOKEN_FREEZE | Token 因超过 active-timeout 活跃频率限制而被冻结 |
| -7 | NO_PREFIX | 配置了 Token 前缀但请求没有携带正确的前缀 |
场景值机制的价值在于:同样是"携带了 Token 但无法通过校验",-4 意味着"你在另一台设备上登录了",-5 意味着"管理员把你踢出去了",-6 意味着"太久没操作了"。前端可以根据不同的场景值展示完全不同的提示,甚至跳转到不同的页面——这是把一个异常拆成 7 个场景值的核心意义。
SaTokenException:Sa-Token 的基类异常,所有框架内部的运行时错误都继承自它。通常不需要单独捕获,在全局兜底处理中用它接住所有未预料到的框架异常即可。
本篇笔记目前只涉及登录会话生命周期,权限相关的 NotPermissionException 和 NotRoleException 将在第三篇引入权限体系时统一处理。
4.2. 创建全局异常处理器
在 com.example.authsatoken 包下新建 exception 子包,创建全局异常处理器:
📄 src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(新建)
1 | package com.example.authsatoken.exception; |
几个关键点说明。
@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 | { |
场景二:携带一个随机伪造的 Token(场景值 -2)
1 | GET http://localhost:8081/auth/info |
预期响应:
1 | { |
场景三:正常登录后访问(应通过校验)
1 | POST http://localhost:8081/auth/login?username=user&password=123456 |
拿到 Token 后:
1 | GET http://localhost:8081/auth/info |
预期响应正常返回会话信息,不触发任何异常。
场景值 -3 到 -6 的触发需要特定条件(Token 过期、被踢出、被顶替、被冻结),将在第五章下线行为的测试中逐一覆盖——届时全局异常处理器会把每一种原因转化为对应的差异化提示,直观感受场景值机制的价值。
4.4. 统一响应格式的约定
目前我们的接口全部使用 SaResult 作为响应体,这是 Sa-Token 内置的一个简单工具类:
1 | // 常用静态工厂方法 |
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 在本系列中的定位,以及与实际项目自定义响应体的对接方式。有了这套机制,第五章的下线行为测试才能真正看到场景值的价值——每种下线方式对应的用户提示,将从这里"翻译"出来。
核心汇总表
| 场景值 | 常量名 | 触发条件 | 对应提示示例 |
|---|---|---|---|
| -1 | NOT_TOKEN | 请求未携带任何 Token | “未提供 Token,请先登录” |
| -2 | INVALID_TOKEN | Token 无效或已注销 | “Token 无效,请重新登录” |
| -3 | TOKEN_TIMEOUT | Token 超过绝对有效期 | “Token 已过期,请重新登录” |
| -4 | BE_REPLACED | 被同设备类型新登录顶替 | “您的账号已在其他设备登录” |
| -5 | KICK_OUT | 被管理员强制踢下线 | “您已被强制下线” |
| -6 | TOKEN_FREEZE | 超过活跃有效期被冻结 | “Token 已被冻结,请稍后重试” |
| -7 | NO_PREFIX | Token 前缀格式不正确 | “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 | // 注销当前请求携带的 Token(最常用,用于"退出登录"按钮) |
我们已经在第一章的登录接口中提供了 POST /auth/logout,它调用的正是第一个无参版本,注销当前请求携带的 Token。用户点击"退出登录"时调用此接口,Token 随即失效。
5.3. kickout:强制踢人下线
kickout 的 API 形态与 logout 完全对称,区别仅在于处理方式——打标记而非删除:
1 | // 将指定账号的所有会话踢下线(全端) |
kickout 是典型的管理员操作,职责上不应该与用户自己的登录注销混在同一个 Controller 里。下一节我们创建独立的 AdminController 来承载它。
5.4. 创建管理员控制器
踢人下线是管理后台的操作,我们将其放在独立的 AdminController 中,接口设计遵循 RESTful 风格——踢人的本质是"删除一个会话资源",所以使用 DELETE 方法。
📄 src/main/java/com/example/authsatoken/controller/AdminController.java(新建)
1 | package com.example.authsatoken.controller; |
三个接口覆盖了三种踢人粒度,从粗到细:按账号全端踢出 → 按账号加设备类型定向踢出 → 按 Token 值精确踢出。资源路径的嵌套结构 /admin/users/{userId}/sessions/{device} 语义清晰——“操作某个用户在某个设备上的会话”。
5.5. 全流程验证
现在重启项目,通过两个完整的测试序列,把 kickout 和 replaced 对应的场景值验证一遍。有了第四章的全局异常处理器,每种下线原因都会翻译成可读的 JSON 提示,而不是一堆 HTML 乱码。
验证 kickout(场景值 -5)
步骤 1:user 账号登录,记录 Token
1 | POST http://localhost:8081/auth/login?username=user&password=123456 |
从响应 data 字段拿到 Token 值,命名为 Token-U。
步骤 2:确认账号在线,查看当前会话信息
1 | GET http://localhost:8081/auth/info |
预期响应中 onlineCount 为 1,tokenList 包含 Token-U。
步骤 3:管理员将 user 账号(userId=10002)全端踢下线
1 | DELETE http://localhost:8081/admin/users/10002/sessions |
预期响应:
1 | { |
步骤 4:用 Token-U 再次访问 /auth/info
1 | GET http://localhost:8081/auth/info |
预期响应:
1 | { |
场景值 -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 | GET http://localhost:8081/auth/info |
预期响应:
1 | { |
场景值 -4 触发——同设备类型的新登录自动将旧 Token 标记为"已被顶替"。
步骤 8:确认 APP 端不受影响
在步骤 5 之前,如果同一账号已经有一个 APP 端的 Token,可以携带它访问 /auth/info——APP 端的会话完全不受 PC 端重新登录的影响,仍然正常返回会话信息。这再次印证了第一章的结论:同端互斥,不同端共存。
在验证过程中有一个细节值得停下来思考:logout 和 kickout 对用户来说体验相似(都是"用不了这个 Token 了"),但 Redis 中的数据状态是不同的。
logout 之后,Redis 中这个 Token 对应的键值被删除,携带它访问接口得到场景值 -2(INVALID_TOKEN)——框架在 Redis 里找不到这个 Token,判断它从未存在或已被清除。
kickout 之后,Redis 中 Token 对应的记录依然存在,但附加了一个 kickout 标记。框架能找到这个 Token,知道它的主人是谁,同时也知道它已被强制下线,于是抛出场景值 -5(KICK_OUT)而非 -2。
这个差异在大多数业务场景下不影响使用,但在某些需要"区分用户是主动退出还是被踢出"的审计日志场景中,场景值的不同会直接决定日志记录的内容。
5.7. 本章总结
本章回顾
本章完成了下线行为体系的完整建设。我们首先从概念层面区分了 logout、kickout、replaced 三种下线方式在触发者、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}/sessions | GET | 查询指定账号在线终端列表 |
/admin/users/{userId}/sessions | DELETE | 踢出指定账号全端 |
/admin/users/{userId}/sessions/{device} | DELETE | 踢出指定账号指定设备 |
/admin/sessions/{token} | DELETE | 踢出指定 Token |
第六章. 账号封禁
阶段式学习路径
第五章讲的是下线——把已登录的人"踢出去"。但踢出去只解决了当下的问题,违规账号退出之后,下一秒就可以重新登录进来。
账号封禁解决的是"进不来"的问题:在封禁期间,无论该账号用什么设备、什么时间尝试登录,都应该被拒之门外。本章从最基础的整体封禁出发,逐步延伸到只限制部分能力的分类封禁、按违规程度递进的阶梯封禁,最后处理封禁数据在缓存重启后的持久化问题。
6.1. 基础封禁
对指定账号发起封禁:
1 | // 封禁指定账号,封禁时长 86400 秒(1 天) |
两个参数的含义:
- 参数 1:要封禁的账号 id。
- 参数 2:封禁时长,单位秒,传入
-1代表永久封禁。
封禁后,在该账号下次尝试登录时校验一下封禁状态,通过校验才允许登录:
1 | // 校验是否已被封禁,如果是则抛出异常 DisableServiceException |
v1.31.0 之后,StpUtil.login() 不再自动校验账号是否被封禁,需要开发者在登录逻辑中手动调用 checkDisable() 进行前置校验。
在我们现有的登录接口中,加入封禁校验:
📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改 login 方法,在执行登录前追加封禁校验)
1 | // 3. 校验该账号是否已被封禁 |
此模块的全部可用 API:
1 | // 封禁指定账号 |
踢 + 封的组合策略
对于当前正在线的违规账号,单纯封禁并不会让它立刻掉线——封禁只影响下次登录,已有的会话仍然有效。如果需要立即生效,采用先踢后封的组合:
1 | // 先将该账号所有在线会话踢下线 |
6.2. 分类封禁
有时候我们并不需要封禁整个账号,而是只限制其访问部分服务。
假设我们在开发一个电商系统,对于违规账号设定三种处罚:
- 账号 A 因多次虚假好评,封禁其 评论能力。
- 账号 B 因多次薅羊毛,封禁其 下单能力。
- 账号 C 因店铺销售假货,封禁其 开店能力。
三项处罚相互独立,封禁评论不影响下单,封禁下单不影响浏览。这就需要分类封禁:
1 | // 封禁指定用户的评论能力,期限 1 天 |
三个参数的含义:
- 参数 1:要封禁的账号 id。
- 参数 2:业务标识(可以是任意自定义字符串)。
- 参数 3:封禁时长,单位秒,
-1代表永久封禁。
在对应的业务接口中进行校验:
1 | // 在评论接口校验评论能力,已被封禁则抛出 DisableServiceException |
分类封禁的完整 API:
1 | // 封禁:指定账号的指定服务 |
6.3. 阶梯封禁
对于多次违规的用户,处罚力度往往需要递进。以一个论坛系统为例,设定三种处罚力度:
- 1 级:封禁发帖、评论能力,但允许点赞、关注。
- 2 级:封禁一切互动能力,但允许浏览。
- 3 级:封禁登录,限制一切能力。
关键在于把处罚力度量化为数字等级,数字越大代表封禁越严。然后使用阶梯封禁 API:
1 | // 将账号 10001 封禁到 3 级,时长 10000 秒 |
DisableServiceException 异常被抛出时,可以从中取出两个关键值:
1 | try { |
当实际封禁等级 >= limitLevel 时,框架就会抛出异常。也就是说,账号被封禁到 3 级时,对 2 级和 3 级的校验都会不通过;对 4 级的校验则依然通过。
分类封禁 + 阶梯封禁的组合
如果业务足够复杂,还可以将两者结合——对某个特定服务按等级封禁:
1 | // 对账号 10001 的 comment 服务执行 3 级封禁,时长 10000 秒 |
6.4. 封禁信息持久化
Sa-Token 默认将封禁信息存储在缓存(Redis)中。缓存数据是"临时性的"——一旦 Redis 重启,封禁记录就会丢失,已被封禁的账号便能重新登录。对于大多数生产系统,封禁数据需要持久化到数据库。
最直接的做法是在调用 Sa-Token 封禁 API 之后,同步写入数据库:
1 | // 在 Sa-Token 中封禁账号 |
这样可以保证封禁数据同时存在于缓存和数据库中。但还有一个问题没解决:如果缓存中间件重启,缓存数据丢失,此时 StpUtil.checkDisable() 将失去约束效果,被封禁的账号可以顺利登录,直到缓存数据被重新同步。
Sa-Token 提供了一种更优雅的方案:实现 StpInterface 的 isDisabled 方法。框架在每次调用 checkDisable() 时,会先查询缓存,缓存未命中时再调用你实现的 isDisabled 方法,从数据库中实时查询封禁状态。这样即使缓存丢失,封禁校验依然有效,且不需要在程序启动时批量同步数据。
StpInterface 将在第三篇讲解权限体系时正式引入。这里只需要知道它是 Sa-Token 的数据源扩展点,isDisabled 是其中负责封禁查询的方法。
1 |
|
SaDisableWrapperInfo 的几种写法:
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 | // 在当前会话开启二级认证,有效期 120 秒 |
openSafe() 和 closeSafe() 是一对开关,isSafe() 和 checkSafe() 的关系与第三章中 isLogin() 和 checkLogin() 完全类似——前者是查询,返回布尔值,不抛异常;后者是校验,不通过直接中断请求。
7.3. 一个完整的业务示例
以"删除仓库"这个高风险操作为例,演示二级认证的完整业务流程:
1 | /** |
完整的调用链路是这样的:
- 前端调用
deleteProject接口,尝试删除仓库。 - 后端发现当前会话尚未通过二级认证,返回"请先完成安全验证"。
- 前端弹出密码输入框,用户输入密码,调用
openSafe接口。 - 后端比对密码通过,为当前会话打开二级认证标记,有效期 120 秒。
- 前端在 120 秒内再次调用
deleteProject接口。 - 后端检测到二级认证有效,执行删除操作,返回成功。
7.4. 指定业务标识
如果项目有多条业务线都需要二级认证,默认的 openSafe() 无法区分"这次验证通过的是哪个操作"。举个例子:用户为了删除仓库完成了密码验证,结果这个验证状态同时也让他能免验证地绑定新手机号——这显然不是我们想要的。
Sa-Token 通过业务标识解决这个问题。不同业务标识的二级认证彼此完全独立,互不干扰:
1 | // 为"删除仓库"操作开启二级认证,有效期 120 秒 |
业务标识可以是任意字符串,由你的业务语义决定。建议使用具有描述性的短语,方便代码阅读和日志排查。
带业务标识的完整 API:
1 | // 开启指定业务的二级认证 |
7.5. 用注解简化校验
如果你不想在每个高风险接口的方法体内写 checkSafe() 的判断逻辑,可以改用注解方式,由拦截器在进入方法之前自动完成校验:
1 | // 必须通过默认二级认证才能访问此接口 |
@SaCheckSafe 注解需要 SaInterceptor 拦截器生效才能工作。该拦截器将在第三篇正式引入,目前如果需要使用注解方式,可以先参考第三篇的配置提前注册。
未通过二级认证时,@SaCheckSafe 会抛出 NotSafeException,建议在 GlobalExceptionHandler 中追加对应的处理:
📄 src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(追加方法)
1 | import cn.dev33.satoken.exception.NotSafeException; |
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 | // 获取指定账号当前的 Token 值(账号有多个在线设备时返回最新一个) |
这些方法在第五章的 AdminController 中已经用过——管理员踢人不需要持有被踢者的 Token,只需要知道 userId 即可。权限相关的跨账号查询将在第三篇引入,这里先建立"可以直接操作任意账号"的基本认知。
8.2. 临时身份切换
有时候,我们不是要"操作"某个账号,而是需要 在当前请求内,临时变成另一个账号。
典型场景:客服系统中,客服人员(userId=9001)已登录,但需要以用户(userId=10002)的视角调用一系列查询接口,看看该用户能看到什么、能操作什么——此时不可能让客服真的注销自己重新用用户账号登录,也不应该修改客服自己的 Token 记录。
Sa-Token 为此提供了 switchTo():
1 | // 将当前会话的身份临时切换为 userId=10044,本次请求内有效 |
switchTo() 只影响当前请求线程内对 getLoginId() 等方法的返回值,不会修改任何 Redis 数据,不会创建新的 Token,请求结束后自动失效。
8.3. Lambda 写法:无需手动关闭
使用 switchTo() + endSwitch() 的写法有一个隐患:如果在切换期间代码抛出异常,endSwitch() 可能不会被执行,导致当前线程的身份状态残留。
Sa-Token 提供了 Lambda 版本,将切换逻辑包裹在一个闭包内,执行完成后(无论是否抛出异常)自动恢复:
1 | 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(),不自动绕过校验 |





