登录注册番外篇(二) - Sa-Token:2026年最新登录模式全解与会话管理
登录注册番外篇(二) - Sa-Token:2026年最新登录模式全解与会话管理
Prorise第一章. 三种登录模式:一个配置搞定多端策略
阶段式学习路径
在番外篇(一)中,我们用 StpUtil.login(10001) 完成了最基础的登录,但没有回答一个关键问题:同一个账号能不能在多个设备上同时登录?如果能,上限是多少?如果不能,新登录是挤掉旧登录还是拒绝?
回忆一下基础篇——为了实现多设备管理,我们手写了 ZSet 存储、顶号逻辑、设备信息提取,三个类协作将近 100 行代码。而 Sa-Token 把这些策略抽象成了两个配置项和一个登录参数。
本章我们将体验 Sa-Token 的三种登录模式,并理解它们背后的配置组合。
1.1. 两个配置项决定登录行为
打开 application.yaml,关注这两个配置:
1 | sa-token: |
is-concurrent 控制"能不能同时登录",is-share 控制"同时登录时是否共用 Token"。两者是独立的维度,组合起来产生三种登录模式:
| is-concurrent | is-share | 登录模式 | 行为描述 |
|---|---|---|---|
| true | true | 多端共享 Token | 多次登录拿到同一个 Token,最宽松 |
| true | false | 多端独立 Token | 每次登录生成新 Token,旧 Token 不失效 |
| false | — | 单端登录 | 新登录自动挤掉旧登录,同一时间只有一个有效会话 |
我们项目当前的配置是 is-concurrent: true + is-share: false,也就是第二种模式——允许多端同时登录,每次登录生成独立的 Token。这是最常用的配置,因为它既允许多设备在线,又能区分不同设备的会话。
is-concurrent=false 时,is-share 的值不影响行为,因为同一时间只会存在一个有效会话。
1.2. 同端互斥登录:QQ 模式
如果你用过腾讯 QQ,你会发现它的策略很有意思:手机和电脑可以同时在线,但不能在两台手机上同时登录。这就是"同端互斥登录"——同类型设备互斥,不同类型设备共存。
在基础篇中我们完全没有实现这个功能,因为它需要在 ZSet 中按设备类型分组管理,复杂度远超当时的架构设计。
而在 Sa-Token 中,实现同端互斥只需要在登录时指定设备类型。回顾一下番外篇(一)中 LoginController 的登录代码——当时我们用的是 StpUtil.login(10001),没有传设备类型。现在我们需要升级它。
📄 文件:src/main/java/com/example/authsatoken/controller/LoginController.java(修改)
将原来的 login 方法替换为以下版本:
1 |
|
这段代码相比番外篇(一)的版本,有三个关键升级:
第一,设备类型参数。 device 参数由前端传入,默认值为 "PC"。SaLoginParameter.setDeviceType(device) 告诉 Sa-Token:这次登录来自什么设备。当同一账号以相同设备类型再次登录时,旧会话会被自动顶替;不同设备类型的会话互不干扰。
第二,角色差异化策略。 管理员账号 setIsConcurrent(false)——不允许并发登录,新登录直接挤掉旧登录。普通用户 setIsConcurrent(true)——允许多端同时在线。这个逻辑在基础篇中需要在 AuthService 里写一大段 if-else 分支,现在只需要一个三元表达式。
第三,设备数量限制。 setMaxLoginCount(isAdmin ? 1 : 2) 限制了同一账号的最大在线设备数。管理员最多 1 个(配合 isConcurrent=false 实现严格单端),普通用户最多 2 个(超过时自动踢掉最早的会话)。回忆一下基础篇——为了实现这个"超限自动踢掉"的逻辑,我们手写了 ZSet 大小判断 → 找到最早 Token → 加入黑名单的整套流程。
SaLoginParameter 的配置优先级高于 application.yaml 的全局配置,适合按角色动态调整登录策略。
重启项目后验证同端互斥的效果:
步骤 1:以 PC 端登录
1 | POST http://localhost:8081/auth/login?username=user&password=123456&device=PC |
步骤 2:以 APP 端登录
1 | POST http://localhost:8081/auth/login?username=user&password=123456&device=APP |
两个会话都有效——因为它们是不同设备类型。
步骤 3:再次以 PC 端登录
1 | POST http://localhost:8081/auth/login?username=user&password=123456&device=PC |
步骤 1 的 PC 端会话被顶替,步骤 2 的 APP 端不受影响。
1.3. 本章小结
本章理解了 is-concurrent 和 is-share 两个配置项的组合逻辑,体验了同端互斥登录的实现方式,并将登录方法升级为支持设备类型、角色差异化策略、设备数量限制的完整版本。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
is-concurrent + is-share 组合 | 项目初始化阶段确定登录策略 | 根据业务需求选择配置组合 |
SaLoginParameter 动态配置 | 不同角色需要不同登录策略时 | 在登录时覆盖全局配置 |
setMaxLoginCount() | 需要限制同时在线设备数时 | 超限自动踢掉最早登录的会话 |
第二章. 三种下线方式与全局异常处理
在第一章中,我们提到"新登录挤掉旧登录"——被挤掉的用户再次访问时,Sa-Token 会抛出 NotLoginException。但"被挤掉"只是下线的一种方式。Sa-Token 一共有三种下线途径,它们的触发场景和用户感知完全不同。
在基础篇中,我们只有两种下线方式:用户主动注销和管理员踢人(将 Token 加入黑名单)。而且这两种方式在用户端的表现完全一样——都是"Token 无效",无法区分原因。
2.1. logout、kickout、replaced 的本质区别
先用一张表说清楚:
| 下线方式 | 触发者 | 场景值 | 典型提示 | 基础篇对应 |
|---|---|---|---|---|
logout | 用户自己 | -2 / -3 | “请重新登录” | AuthService.logout() + 黑名单 |
kickout | 管理员 / 系统 | -5 | “您已被管理员强制下线” | 黑名单(无法区分场景) |
replaced | 新登录自动触发 | -4 | “您的账号已在其他设备登录” | ZSet 顶号(无法区分场景) |
logout 是正常退出——番外篇(一)中我们已经在 LoginController 里写过 StpUtil.logout()。除了注销当前会话,它还支持更灵活的用法:
1 | StpUtil.logout(10001); // 注销指定账号的所有会话(全端下线) |
kickout 是管理员视角的强制下线。replaced 是单端登录模式下新登录自动顶替旧登录。三者的 API 形式相似,但场景值不同——这让前端可以根据不同原因展示差异化的提示信息。
2.2. 创建管理控制器
踢人下线是管理员操作,我们把它放在独立的 AdminController 中,与用户侧的 LoginController 职责分离。接口设计遵循 RESTful 风格——踢人本质上是"删除会话",所以使用 DELETE 方法。
📄 文件:src/main/java/com/example/authsatoken/controller/AdminController.java(新建)
在 controller 包下创建 AdminController.java:
1 | package com.example.authsatoken.controller; |
注意接口设计的几个细节:
路径使用资源嵌套结构 /admin/users/{userId}/sessions,语义清晰——“管理员操作某个用户的会话”。DELETE 方法表示删除资源。@PathVariable 从路径中提取参数,而不是用查询参数。
三个接口覆盖了三种踢人粒度:按账号全端踢出、按账号 + 设备类型定向踢出、按 Token 值精确踢出。在基础篇中,我们只实现了"按 Token 加入黑名单"一种方式,按设备类型踢出完全没有涉及。
2.3. NotLoginException 场景值与全局异常处理
被踢下线的用户再次访问接口时,Sa-Token 会抛出 NotLoginException。如果不做处理,Spring Boot 会返回默认的 Whitelabel Error Page。我们需要一个全局异常处理器来统一捕获并返回规范的 JSON 响应。
先了解 NotLoginException 的全部场景值:
| 场景值 | 常量名 | 含义 |
|---|---|---|
| -1 | NOT_TOKEN | 请求中没有携带 Token |
| -2 | INVALID_TOKEN | Token 不存在或已被注销 |
| -3 | TOKEN_TIMEOUT | Token 超过有效期 |
| -4 | BE_REPLACED | 被新登录顶替 |
| -5 | KICK_OUT | 被管理员踢出 |
| -6 | TOKEN_FREEZE | Token 超过活跃频率被冻结 |
| -7 | NO_PREFIX | Token 未携带配置要求的前缀 |
📄 文件:src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(新建)
在 com.example.authsatoken 包下新建 exception 目录,然后创建 GlobalExceptionHandler.java:
1 | package com.example.authsatoken.exception; |
这里使用了 Java 17 的 switch 表达式,比传统的 switch-case-break 更简洁。@RestControllerAdvice 让这个类成为全局异常处理器,@ExceptionHandler(NotLoginException.class) 指定只捕获 NotLoginException。
重启项目后验证效果:
步骤 1:登录
1 | POST http://localhost:8081/auth/login?username=user&password=123456 |
步骤 2:踢人下线
1 | DELETE http://localhost:8081/admin/users/10001/sessions |
步骤 3:用被踢的 Token 访问鉴权接口
1 | GET http://localhost:8081/auth/check |
预期响应:
1 | { |
如果是未携带 Token 直接访问,返回的则是 "未提供 Token,请先登录"。不同场景值,不同提示——这是基础篇的黑名单方案做不到的。
在实际项目中,你可以在这个异常处理器中同时捕获 NotPermissionException、NotRoleException 等其他 Sa-Token 异常,我们会在番外篇(三)中讲解。
2.4. 本章小结
本章区分了 Sa-Token 的三种下线方式(logout / kickout / replaced),创建了 RESTful 风格的 AdminController 实现管理员踢人功能,并通过全局异常处理器将 NotLoginException 的七大场景值转化为差异化的中文提示。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| logout / kickout / replaced | 设计下线策略时 | 根据触发者选择对应方法 |
AdminController RESTful 接口 | 管理员需要踢人下线时 | DELETE /admin/users/{id}/sessions |
GlobalExceptionHandler | 项目需要统一错误响应时 | 通过场景值返回差异化提示 |
第三章. 会话查询与设备管理
登录和下线都搞定了,接下来是另一类常见需求:查看当前账号在哪些设备上登录了?能不能远程踢掉某个设备?
在基础篇中,我们为此在 TokenRedisManager 中用 ZSet 存储设备信息,在 AuthService 中遍历解析 DeviceInfo 列表,两个类协作才完成设备列表查询。在 Sa-Token 中,一个 API 就够了。
3.1. 查询会话列表
我们需要在 LoginController 中新增两个查询接口:一个查当前登录用户自己的会话列表,一个按用户 ID 查询(供管理后台使用)。
📄 文件:src/main/java/com/example/authsatoken/controller/LoginController.java(修改)
在已有方法下方追加:
1 | /** |
别忘了添加 import java.util.List;。
StpUtil.getTokenValueListByLoginId() 返回指定账号当前持有的所有 Token 列表。每个 Token 代表一个活跃的登录会话。如果用户在两个设备上登录了(is-concurrent=true, is-share=false),列表就包含两个 Token 值。
重启项目后验证:先以 PC 和 APP 两种设备类型各登录一次,然后访问 /auth/tokenList——你会看到两个不同的 Token 值。
3.2. 远程踢出指定设备
有了会话列表和第二章的 AdminController,我们可以串联出一个完整的设备管理流程:
- 用户在"账号安全"页面调用
GET /auth/tokenList获取自己的 Token 列表 - 前端展示设备列表,每个 Token 对应一个设备
- 用户点击"下线",前端调用
DELETE /admin/sessions/{token}踢掉该设备
这个流程和基础篇中的"远程踢出设备"功能完全对应。但对比代码量:基础篇需要 DeviceInfo 模型、TokenRedisManager.getDeviceList()、AuthService.kickDevice()、AuthController.kickDevice() 四个类协作;Sa-Token 只需要两个现成的 API。
如果需要展示设备的详细信息(登录时间、设备名称等),可以在登录时将信息存入 Session,我们会在后续篇章讲解。
3.3. 本章小结
本章实现了会话列表查询和远程踢出指定设备,完成了基础篇设备管理模块的全部复刻。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
getTokenValueListByLoginId() | 需要展示设备列表时 | 获取指定账号的所有活跃 Token |
GET /auth/users/{id}/sessions | 管理后台查询用户会话时 | RESTful 资源路径 + PathVariable |
| 查询 + 踢出串联 | 用户远程管理自己的设备时 | 列表接口 + DELETE 接口组合使用 |
第四章. 基础篇 vs Sa-Token:全面对比
经过前三章的实战,我们已经用 Sa-Token 复刻了基础篇登录认证模块的所有核心功能。现在做一次系统性的对比——不是为了"踩"手写方案,而是让两种方案的优势和教学价值都更加清晰。
4.1. 功能对比
| 功能 | 基础篇实现方式 | Sa-Token 实现方式 |
|---|---|---|
| 用户登录 | JwtUtil.generateToken() + TokenRedisManager.storeToken() | StpUtil.login(id) |
| 用户注销 | Token 加入黑名单 + 从 ZSet 移除 | StpUtil.logout() |
| 全设备注销 | 遍历 ZSet 逐个加入黑名单 | StpUtil.logout(id) |
| 按设备类型注销 | ❌ 未实现 | StpUtil.logout(id, "PC") |
| 踢人下线 | Token 加入黑名单(无法区分场景) | StpUtil.kickout(id),场景值 -5 |
| 顶替下线 | ZSet 顶号逻辑(无法区分场景) | 自动触发,场景值 -4 |
| 多端登录控制 | ZSet 大小判断 + 手动顶号 | is-concurrent 配置项 |
| 同端互斥登录 | ❌ 未实现 | StpUtil.login(id, "PC") |
| 设备数量限制 | ZSet 大小判断 + 移除最早 Token + 加入黑名单 | SaLoginParameter.setMaxLoginCount(3) |
| 设备列表查询 | 遍历 ZSet + 解析 DeviceInfo | StpUtil.getTokenValueListByLoginId(id) |
| Token 黑名单 | BlacklistRedisManager(手动管理 TTL) | 框架内部自动管理 |
| 异常场景区分 | ❌ 所有失败统一返回"Token 无效" | 7 种场景值,差异化提示 |
12 个功能点中,基础篇有 3 个完全未实现,其余 9 个都需要大量手写代码。Sa-Token 全部支持,且大多数只需要一行代码或一个配置项。
4.2. 代码量对比
基础篇(auth 项目)
| 文件 | 职责 | 核心代码行数 |
|---|---|---|
RsaKeyManager.java | RSA 密钥加载 | ~60 行 |
JwtUtil.java | Token 生成与解析 | ~80 行 |
JwtProperties.java | JWT 配置绑定 | ~30 行 |
TokenRedisManager.java | Token 存储(ZSet) | ~120 行 |
BlacklistRedisManager.java | 黑名单管理 | ~50 行 |
RedisKeyConstants.java | Redis Key 常量 | ~20 行 |
AuthToken.java | 双令牌模型 | ~30 行 |
DeviceInfo.java | 设备信息模型 | ~40 行 |
DeviceInfoExtractor.java | 设备信息提取 | ~50 行 |
AuthService.java | 认证 Facade | ~150 行 |
AuthController.java | 认证控制器 | ~80 行 |
| 合计 | 11 个 Java 文件 | ~710 行 |
Sa-Token 方案(auth-satoken 项目,截至本篇)
| 文件 | 职责 | 核心代码行数 |
|---|---|---|
LoginController.java | 登录 / 注销 / 查询 | ~60 行 |
AdminController.java | 管理员踢人下线 | ~30 行 |
GlobalExceptionHandler.java | 全局异常处理 | ~25 行 |
| 合计 | 3 个 Java 文件 | ~115 行 |
11 个文件 710 行 vs 3 个文件 115 行,代码量减少约 84%。而且 Sa-Token 方案的功能覆盖面更广。
4.3. 扩展性对比
用三个真实需求场景来验证扩展性差异:
场景一:新增"7 天免登录"
基础篇需要修改 JwtProperties(增加配置项)、JwtUtil(支持动态有效期)、TokenRedisManager(ZSet score 计算),三个文件联动。Sa-Token 只需要在登录时传入参数:
1 | StpUtil.login(10001, new SaLoginParameter() |
场景二:新增"管理员单端、普通用户多端"
基础篇需要在 AuthService 中根据角色走不同分支,修改顶号策略,核心逻辑高度耦合。Sa-Token 在第一章中已经实现了——就是 SaLoginParameter 的三元表达式。
场景三:新增"按设备类型踢出"
基础篇需要在 ZSet 中存储设备信息并做过滤查询,改动量大且风险高。Sa-Token 已经内置了 StpUtil.kickout(id, "PC"),零改动。
每个扩展场景,基础篇都需要修改多个文件,Sa-Token 只需要调整登录参数或调用现成 API。这就是框架封装带来的扩展性优势。
老师,既然 Sa-Token 这么方便,那基础篇的手写代码是不是白学了?
恰恰相反。正是因为你亲手写过 ZSet 顶号逻辑,你才能理解 maxLoginCount 背后做了什么。正是因为你手写过黑名单 TTL 计算,你才能理解 kickout 和 logout 为什么要区分场景值。框架帮你省掉的是重复劳动,但理解原理的能力是框架给不了你的。
明白了,手写是为了理解,框架是为了效率。
对。而且当框架出了 Bug 或者不满足需求时,你有能力看懂源码、定位问题、甚至自己扩展。这就是手写轮子的长期价值。
4.4. 本章小结
本章从功能覆盖、代码量、扩展性三个维度做了全面对比。Sa-Token 在 12 个功能点上全部支持(基础篇有 3 个未实现),代码量减少约 84%(710 行 → 115 行),新增需求时只需调整参数而无需修改多个文件。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 功能对比表 | 技术选型或方案评审时 | 逐项对比手写方案和框架方案 |
| 代码量对比 | 评估开发效率时 | 关注文件数量和核心代码行数 |
| 扩展性对比 | 评估长期维护成本时 | 用"新增需求"场景验证改动范围 |






