做 Java 后端这几年,登录认证这块踩过的坑,比想象中要多得多。早期项目里我习惯用拦截器加 ThreadLocal 自己写一套:登录成功后把 userId 往 Map 里一塞,全程好像也没什么问题,但一旦涉及分布式部署、多端互踢、会话失效、在线状态查询这些需求,代码就开始失控。后来切到 SaToken,登录、Token 签发、会话存取全被框架包办,真正让我觉得和之前方案拉开差距的,是密码加密与会话查询这两块结合起来之后的完整度:底层用 BCrypt 把用户密码安全落库,登录成功后自动建立会话,业务侧又能随时根据 Token 或 LoginId 把会话里的上下文捞出来,整个过程一气呵成,不再需要我拿一堆工具类拼凑。这篇文章就围绕这套链路展开,适合正在做登录鉴权、想给项目换上更规范认证方案的 Java 后端同学,也适合中小团队想快速落地一套可靠登录体系的场景。
我以一个实际改造过的“某内部运营后台”为例,把从密码加密、登录会话建立,再到会话查询这条完整链路一步步拆开讲,顺带把我在实操中踩过的坑和排查方法一并写出来。
1. 整体设计:密码加密与会话查询为什么必须放在同一套链路里
1.1 从“密码能登录”到“会话可追溯”的完整闭环
很多项目的登录模块是“能登录就行”,登录接口里写死一段查询:比对用户名密码,匹配了就往 Redis 里丢一个随机串,下次请求带着这个串过来,再由过滤器查一次 Redis。问题在于,这套逻辑往往只解决了“入口”问题,没有解决“状态管理”问题。用户登录之后,他是谁、什么时候登录的、token 是否有效、当前账号在线设备有几台,这些信息散落在各个表甚至各个服务里,排查问题时要翻代码、翻日志,效率很低。
密码加密与会话查询看起来是两个独立技术点,但本质上是一条认证链路的上下游。密码加密保证的是“凭据安全”:数据库即使泄露,攻击者也拿不到明文密码;会话查询保证的是“状态可追踪”:用户持 token 访问系统时,系统能快速解析出对应的 LoginId,并读取该用户登录时写入会话的上下文信息。两者缺一环,登录体系都不完整。
打个比方,密码加密相当于给钥匙配了一把可靠的锁,登录成功是门禁打开,会话查询则是门禁卡背后记录的访客信息——你什么时候进来的、去了哪个区域、当前的权限状态如何。如果只做了密码加密,系统能确认“这串密码对不对”,却无法知道“这个人当前是谁”;如果只做了会话查询,身份凭证的安全性又无从保障。所以实际设计时,我把密码校验、会话建立、会话查询收拢到同一个认证模块里,前端只负责提交用户名密码,后端统一处理加密比对、生成 Token、写入会话上下文,后续所有接口通过 StpUtil 就能拿到当前用户和会话数据,不再各自为政。
1.2 为什么是 SaToken 来承载这套会话体系
市面上的认证框架不少,Spring Security 全家桶能力很强但配置成本高,Shiro 的 Session 概念偏重且很多项目只在用它的登录部分。SaToken 的优势在于轻量和贴近业务:它把登录(StpUtil.login)、注销(StpUtil.logout)、会话获取(StpUtil.getSession)、踢人下线(StpUtil.kickout)这些高频能力封装成静态方法,站在调用方视角,几乎不用理解底层实现就能把登录链路搭起来。
另一个选它的原因是它的“会话模型”很清晰。SaToken 把登录主体抽象成 LoginId(用户 ID),登录时生成 Token 返回给前端,同时维护一个以 LoginId 为维度的 SaSession 对象,业务数据可以随时塞进去再查出来。这对“会话查询”这个需求来说是天然匹配的:我想查某个用户最近一次登录时间,直接 StpUtil.getSessionByLoginId(loginId) 就能拿到之前写入的 loginTime;我想知道某个 token 对应哪个用户,StpUtil.getLoginIdByToken(tokenValue) 一行搞定。
版本上我目前用的是 SaToken 1.38.x,配合 Spring Boot 3 需要引入 sa-token-spring-boot3-starter,如果是 Spring Boot 2 项目则用 sa-token-spring-boot-starter。API 在近几个版本里变化不大,下面示例代码可以平移到其他版本。选型落地前,我对照过自己手写的拦截器方案:SaToken 本质上也是在拦截器/过滤器层做 Token 解析和登录校验,但它把“存储位置、过期策略、多端登录互踢”这些容易出错的细节全都收敛到配置项里,稳定性和可维护性比散落的自研代码强得多。
2. 密码加密与会话模型的关键细节
2.1 密码加密:为什么答案几乎总是 BCrypt
很多老项目用的是 MD5 加固定盐,甚至直接明文存数据库。MD5 和 SHA-256 这俩哈希算法本身并没什么“被破解”的说法,它们的问题在于计算速度太快,攻击者用 GPU 可以每秒跑几十亿次,配合彩虹表,对常见弱密码基本是秒破。即便加了盐,如果盐是固定的,同一个密码在不同用户间仍然会产生相同哈希,攻击者一旦分析出规律,很容易批量还原。
BCrypt 从根本上解决了这两点:第一,每个密码加密时会生成随机盐,所以同一个明文密码两次加密后的结果完全不同;第二,BCrypt 通过一个可调的成本因子(cost factor)控制计算耗时,默认 10 时单次加密大约几毫秒到十几毫秒,成本因子每提高 1,耗时约翻一倍,攻击者想要暴力穷举的成本指数级上升。这两点结合起来,让 BCrypt 成了目前密码存储最主流的方案。
实际写代码时我直接用的 Spring Security 的 crypto 模块,只引入一个依赖,不需要把整个 Security 框架引进来:
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency>单测里可以直观看到 BCrypt 的效果:
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String hash1 = encoder.encode("123456"); String hash2 = encoder.encode("123456"); System.out.println(hash1.equals(hash2)); // false System.out.println(encoder.matches("123456", hash1)); // true两次加密结果不同,正是随机盐在起作用。matches 方法会从密文中提取盐信息,再用相同成本因子重新计算比对,所以业务代码里永远不要去解密,只调 matches 校验。
成本因子的选择值得单独说一下。默认 10 在普通服务器上单次校验大概 5-15ms,压测 1000 并发登录时影响不大。如果你对安全性要求更高,可以调到 12,但要注意计算耗时大约会翻到 30-60ms,对登录接口的 QPS 有明显影响。我自己的经验是:对内网运营后台用默认 10 足够,对外开放的高价值账号系统建议用 12,再高就有点得不偿失了。另外,数据库里 BCrypt 密文固定 60 个字符,建表时 password 字段一定不要只给 32 或 64 长度,建议直接 varchar(100),给未来算法升级留足空间。
2.2 SaToken 会话模型:Token、LoginId、SaSession 三层关系
刚开始用 SaToken 的人最容易把这三者搞混。Token 是发给前端的凭证,登录成功后通过 StpUtil.getTokenInfo() 可以拿到 tokenName、tokenValue、过期时间等;LoginId 是登录主体的唯一标识,通常就是用户表的主键;SaSession 是按 LoginId 维度存业务数据的会话对象,类似 HttpSession 的升级版,用来保存昵称、登录时间、角色等上下文信息。
这里有个容易忽略的细节:一个 LoginId 可以对应多个 Token。用户在 PC 和手机端同时登录,SaToken 会生成两个不同 Token,但 LoginId 是同一个。会话查询时如果按 LoginId 查 Session,拿到的是这个账号共用的数据;如果按 Token 查,则能区分出具体是哪一次登录产生的会话。SaToken 还额外提供了 Token-Session(StpUtil.getTokenSession()),这是以单个 Token 为维度存数据的会话,适合放“本次登录特有的数据”,比如当前登录设备的 UA、IP。
下面这张表总结了我日常最常用的会话查询 API:
| 方法 | 作用 | 适用场景 |
|---|---|---|
| StpUtil.getTokenInfo() | 获取当前请求 Token 的完整信息 | 登录成功返回给前端、调试排查 |
| StpUtil.getLoginId() | 获取当前请求对应的用户 ID | 业务接口里快速拿到当前用户 |
| StpUtil.getLoginIdByToken(tokenValue) | 根据指定 Token 获取用户 ID | 网关转发、后台按 Token 查询用户 |
| StpUtil.getSessionByLoginId(loginId) | 按用户 ID 获取 Session | 读取登录时写入的业务上下文 |
| StpUtil.getTokenValueByLoginId(loginId) | 按用户 ID 获取 Token | 查询某账号当前的登录凭证 |
| StpUtil.kickout(loginId) | 强制指定账号下线 | 后台禁用账号、安全拦截 |
理解这层模型后,会话查询就不再是“查 Redis 里的随机 key”,而是围绕逻辑身份(LoginId)来组织数据。为了把这个模型落地,我强烈建议在一开始就约定好:密码加密和会话建立都收敛在认证服务中,其他业务模块要拿当前用户信息,唯一入口是 StpUtil 封装出来的方法,不要自己再造一套缓存。这样整个系统的会话状态是单一来源,排查问题时思路清晰很多。
3. 实操落地:从依赖配置到注册、登录、会话查询全链路
3.1 工程准备:依赖、配置与表结构
项目基于 Spring Boot 3 + SaToken 1.38.x。先在 pom 里引入 SaToken 和 Redis 相关依赖:
<dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot3-starter</artifactId> <version>1.38.0</version> </dependency> <!-- 如果要做分布式会话共享,再加这个 --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-redis-jackson</artifactId> <version>1.38.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>注意 sa-token-redis-jackson 只能配合 Redis 使用,如果项目还在单机内存模式下跑,不需要引入。我实际改造时是上线前统一加上的,因为运营后台可能会部署多个实例,会话数据只存在本机内存会导致请求打到其他节点时查不到 Session。
核心配置放在 application.yml:
sa-token: token-name: satoken timeout: 2592000 active-timeout: -1 is-concurrent: true is-share: false token-style: uuid is-log: false几个关键配置的解释:timeout 是 Token 的固定有效期,单位秒,2592000 是 30 天;active-timeout 是“临时有效期”,-1 表示永不自动过期,只受固定有效期约束;is-concurrent 控制同一账号是否能并发登录多个设备,true 表示允许多端登录;is-share 控制同账号多次登录是否共用同一个 Token,false 表示每次登录生成新 Token,这样就能做到“两处登录互不影响,且能分别管理”。我把 is-share 设为 false 的原因后面会话查询时会讲到:只有每个 Token 独立,后台才能准确踢掉某个设备而不影响其他设备。
用户表结构可以用最简单的模型:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );password 字段用 VARCHAR(100) 就是为了放下 BCrypt 的 60 位密文,并给未来算法迁移留空间。表结构越简单越好,重点是不要在数据库里存任何明文密码或可逆加密后的密码。
3.2 注册接口:把 BCrypt 加密逻辑放到用户落库之前
注册是密码加密的第一个落点。用户在页面填用户名和密码,后端拿到明文后,绝不能在业务代码里明文流转,更不要写进日志。我在实际项目中见过有人把注册请求体直接打印到日志里,这就是妥妥的安全事故。
一个常规的注册服务代码如下:
@Service public class AuthService { private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(10); public void register(RegisterBody body) { // 1. 校验用户名唯一,保证并发安全 if (userMapper.selectByUsername(body.getUsername()) != null) { throw new RuntimeException("用户名已存在"); } // 2. 加密明文密码 String encodedPassword = passwordEncoder.encode(body.getPassword()); // 3. 落库 SysUser user = new SysUser(); user.setUsername(body.getUsername()); user.setPassword(encodedPassword); user.setNickname(body.getNickname()); userMapper.insert(user); } }这里要强调一点:encode的结果已经包含随机盐,数据库里不需要再单独建一个 salt 字段。很多人从旧系统迁移过来时总想保留“密码 + 盐”两个字段,在 BCrypt 方案里完全没必要,盐信息已经在密文里了。这也是我改造老系统时经常要跟团队强调的。
另外,前端如果传输的密码就做了 MD5/加盐等二次处理,后端的 BCrypt 加密就等于只对一个“已经被处理过的值”加密,密码强度再高也无济于事。最稳妥的方案是前后端协商好:前端只负责 HTTPS 传输,后端统一做 BCrypt 加密,不要在链路上再套一层自创的加密逻辑,否则将来排查问题会非常痛苦。
3.3 登录接口:密码校验与会话建立的整合
登录接口是整条链路的核心。老项目的登录接口通常只返回一个“成功”或“失败”,新方案里我让登录成功后直接返回 SaTokenInfo,前端拿到 token 存起来,后续请求放在 Header 里传过来即可。
代码逻辑分三步走:查用户、校验密码、建立会话。其中建立会话不是只调一个 login 就完事,我会顺手把业务上下文写进 Session,这也是“会话查询”能落地的基础:
public SaTokenInfo login(LoginBody body) { SysUser user = userMapper.selectByUsername(body.getUsername()); // 不区分“用户不存在”和“密码错误”,统一提示,避免用户名枚举 if (user == null || !passwordEncoder.matches(body.getPassword(), user.getPassword())) { throw new RuntimeException("用户名或密码错误"); } // 1. 登录:SaToken 内部会生成 Token 并与 LoginId 绑定 StpUtil.login(user.getId()); // 2. 把用户基础信息写入 Session,供后续查询 StpUtil.getSession().set("userId", user.getId()); StpUtil.getSession().set("nickname", user.getNickname()); StpUtil.getSession().set("loginTime", LocalDateTime.now()); StpUtil.getSession().set("loginIp", SaHolder.getRequest().getRemoteHost()); // 3. 返回完整的 Token 信息 return StpUtil.getTokenInfo(); }这里出现了一个很多新手都会问的点:StpUtil.getSession()到底获取的是谁的 Session?答案是通过当前线程上下文里的 LoginId 去获取,也就是刚刚 login 过的那个用户。如果你还没登录就调 getSession(),SaToken 会抛异常。所以正确顺序是先 login,再往 Session 里写数据。也可以显式调用StpUtil.getSessionByLoginId(user.getId()),效果相同,只是我更习惯用无参版,代码更简洁。
StpUtil.login(user.getId())这一步后端会完成大量工作——生成 Token、建立 LoginId 与 Token 的映射、创建 SaSession 对象、按配置记录过期时间。做完这些之后,“会话”这个概念在系统里就真正存在了,后续所有会话查询都是围绕这次建立的关联关系展开。
3.4 会话查询接口:从 Token 到身份的完整检索
会话查询的能力拆开看,其实就是几个接口:当前登录用户是谁、指定 Token 属于谁、指定用户有哪些会话数据。我通常会在认证模块里提供三个查询端点,覆盖不同场景。
第一个是“获取当前登录人信息”,前端进入个人中心或需要展示当前用户资料时调用:
@GetMapping("/auth/me") public Map<String, Object> me() { StpUtil.checkLogin(); Long loginId = StpUtil.getLoginIdAsLong(); SaSession session = StpUtil.getSessionByLoginId(loginId); return Map.of( "userId", loginId, "nickname", session.get("nickname"), "loginTime", session.get("loginTime"), "loginIp", session.get("loginIp"), "tokenInfo", StpUtil.getTokenInfo() ); }第二个是“根据 Token 查用户”,这个在网关层或后台运营排查时非常有用。比如客服收到用户反馈,但用户说不清自己的用户 ID,客服只能拿到浏览器里的 token 值,这时候运营后台就需要通过 token 反查用户身份:
@GetMapping("/auth/who") public Long who(@RequestParam String tokenValue) { Object loginId = StpUtil.getLoginIdByToken(tokenValue); if (loginId == null) { throw new RuntimeException("token 无效或已过期"); } return Long.valueOf(String.valueOf(loginId)); }第三个是“按用户查会话数据”,通常用在后端内部逻辑。比如运营后台要批量统计某用户最近登录时间、在线设备列表,可以直接按 LoginId 读取 Session:
@GetMapping("/auth/session/{userId}") public Map<String, Object> session(@PathVariable Long userId) { SaSession session = StpUtil.getSessionByLoginId(userId, false); if (session == null) { return Map.of("online", false); } return Map.of( "online", true, "nickname", session.get("nickname"), "loginTime", session.get("loginTime") ); }注意 getSessionByLoginId 的第二个参数false,表示如果该用户没有 Session(即没登录)时不创建新 Session,直接返回 null。这个细节很重要,否则一个从未登录过的用户 ID 会被凭空创建一个空 Session,在线状态判断就失真了。
我在实际项目中把这三个查询接口归到了同一个 AuthController 里,并统一加了 Admin 角色校验,避免普通用户通过“反查接口”探测其他用户的 Token 与登录状态。会话查询能力本身没有安全问题,但它暴露的信息足够敏感,必须做好权限控制。
4. 常见问题与排查实录
4.1 BCrypt 校验失败的几个隐蔽原因
我在项目里遇到最多的坑,不是 BCrypt 算法本身的锅,而是使用方式不对。第一个高频问题是数据库字段长度不够。旧表里 password 字段是 varchar(32),加密后的密文根本存不进去,要么报错要么被截断,截断后 matches 自然永远返回 false。排查方法很简单:把数据库里的 password 字段完整打印出来,数一数是不是 60 个字符,有$2a$10$前缀就说明加密没问题,长的不对就是字段长度问题。
第二个坑是链路上做了二次编码。有次前端同学好心在提交前用 Base64 把密码编码了一下,后端收到后没有解码直接走 BCrypt,导致正常密码永远匹配失败。排查这类问题时,我习惯在 matches 这一步前后分别打印入参密码和数据库密文,用最简单的输入输出对比定位是在哪一环出了问题。
第三个坑是 BCrypt 版本前缀不一致。不同语言实现生成的密文可能带$2a$、$2b$、$2y$前缀,新版本 Spring Security 的 BCryptPasswordEncoder 对几种前缀基本都能兼容,但如果你从别的语言系统迁移过来,最好先写个单元测试确认 matches 能通过,不要盲目相信跨语言兼容性。
4.2 会话查询查不到数据或查到旧数据
这个问题的原因通常分三种。第一种是 Token 已经过期或未登录,此时 StpUtil.getLoginId() 会抛出 NotLoginException。应对方式是在查询入口统一调用 checkLogin() 或者捕获异常后返回 401,不要放任异常堆栈直接暴露给前端。
第二种是分布式环境下 Session 没有共享。只在单机内存模式下,Session 存在 JVM 本地,请求如果被负载均衡打到另一台机器,自然查不到 LoginId。此时需要引入 sa-token-redis-jackson 插件,并确保 Redis 连接配置正确。注意引入插件后,写入 Session 的对象必须实现 Serializable 接口,否则 Redis 序列化会报错。我在实际项目中就踩过一次:往 Session 里塞了一个没实现 Serializable 的 DTO,结果在本地测试一切正常,部署到双节点后接口直接 500,排查了半天才定位到是序列化问题。
第三种是会话数据过期后被清掉,但 Token 还在。active-timeout 如果设置为 600,用户超过 10 分钟没有操作,Session 会被清除,但固定有效期还没到,所以 Token 依然能通过校验。这时候 StpUtil.getLoginId() 能拿到值,但 Session 里之前写入的 nickname、loginTime 全都没了。解决方案有两个方向:要么把 active-timeout 设为 -1,只依赖固定有效期;要么在会话数据被清空时容忍查询结果为 null,前端再做降级处理。
4.3 多端登录、踢人下线与会话查询的配合
多端登录在运营后台几乎是刚需,管理员可能在办公电脑、家里电脑、手机上同时登录。SaToken 里 is-concurrent=true 允许多端登录,is-share=false 保证每次登录生成新 Token。此时会话查询会面临一个容易混淆的场景:同一个 LoginId 下有多个 Token,每个 Token 有自己独立的 Token-Session,但按 LoginId 查的 SaSession 是共用的。
如果你要做“查看某账号所有在线会话”的功能,要注意不能用 SaSession 的数据代表某个具体设备的登录状态,而应该遍历该 LoginId 对应的 Token 列表。SaToken 提供了一个底层接口CheckTokenDao来按条件搜索 Token,但内存模式下默认不可用,需要引入 Redis 插件后才会生效。实际项目里如果想做在线用户列表,更稳妥的做法是在登录成功时自己往 Redis 里维护一份“userId -> token 列表”的映射,查在线列表时直接读这份映射,避免依赖框架底层的搜索能力。
踢人下线时,StpUtil.kickout(loginId)会将该用户所有 Token 强制失效。如果只想踢掉某个设备,需要先拿到该设备的 tokenValue,再调StpUtil.logoutByTokenValue(tokenValue)。我在做后台“强制下线”功能时就遇到过:直接调用 kickout 把用户所有端都踢了,用户反馈手机端也掉了线。后来改成按 tokenValue 定向踢人,才符合运营需求。这个细节建议在设计会话查询时就想清楚,否则后期功能迭代容易反复。
4.4 问题排查速查表
| 症状 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| BCrypt matches 永为 false | 密文被截断、二次编码、版本差异 | 打印密码与密文对比 | 修正字段长度、统一编码链路、加单测 |
| getLoginId 抛异常 | Token 未登录或已过期 | 捕获 NotLoginException | 统一 checkLogin,返回 401 |
| 查 Session 返回 null | 未登录、Session 共享未配置 | 确认 Redis 插件和序列化 | 引入 Redis 共享、实现 Serializable |
| Session 数据丢失 | active-timeout 主动过期 | 检查配置 | 调整 active-timeout 为 -1 |
| 多端登录乱套 | is-share 配置不明 | 核对各端 Token 值 | is-share=false,按 tokenValue 管理 |
速查表是我每次做认证模块都会整理的东西,它不一定覆盖所有异常,但能让接手的人第一时间定位方向,而不是从头翻代码。
5. 经验扩展:把会话查询能力做得更顺手
5.1 封装一个“当前用户”服务,避免散落调用
StpUtil 的 API 足够简洁,但我还是建议在项目里再包一层,给业务方提供更友好的方法。直接业务代码里到处调 StpUtil.getLoginId()、StpUtil.getSession(),将来如果要换框架或统一加缓存,改造成本会很高。封装成 AuthService 之后,业务层只依赖自己的方法签名,底层实现可以随时替换。
我的做法大致如下:
@Service public class AuthService { public LoginUser currentUser() { StpUtil.checkLogin(); Long userId = StpUtil.getLoginIdAsLong(); SaSession session = StpUtil.getSessionByLoginId(userId); LoginUser loginUser = (LoginUser) session.get("loginUser"); if (loginUser == null) { loginUser = loadFromDb(userId); session.set("loginUser", loginUser); } return loginUser; } }这里把用户信息序列化进 Session 后,可以避免每次请求都查一次数据库。但要注意数据一致性问题:用户修改了昵称后,Session 里缓存的 loginUser 还是旧值。解决方法是:修改昵称的接口里同步调用一次 session.set("loginUser", newLoginUser),主动刷新缓存。我实际遇到过一次“改了昵称页面不更新”的问题,就是因为只改了数据库,没刷新 Session 缓存,后来统一约定“用户信息变更必须走 AuthService 的刷新方法”,问题才彻底解决。
封装之后还有个好处:以后要加登录 IP 白名单、账号状态校验、权限注解,都可以收敛在 AuthService 里,不用改业务代码。
5.2 会话数据与安全审计的深度整合
会话查询不只是“查一查当前用户”这么简单,它完全可以作为安全审计的基础数据来源。我在这套体系里额外做了两件事。
第一件事是记录登录审计日志。登录成功时,除了往 Session 写入 loginTime、loginIp,同时落一条审计表记录:用户 ID、登录时间、登录 IP、User-Agent。后续做安全风控时,能快速拉出某用户的登录历史,分析是否有异常 IP。这里要用 Token-Session 而非普通 Session 来存设备相关信息,因为普通 Session 是所有端共用的,存进去会被后登录的设备覆盖。Token-Session 每端独立,天然适合放设备级别的信息。
第二件事是支持“按用户踢除指定设备”。借助前面提到的 is-share=false 配置,每个设备有独立 Token,管理后台可以列出用户所有在线设备,再针对某个 tokenValue 单独踢出。安全审计界面常配的“退出其他所有设备”也能实现:遍历用户 token 列表,保留当前 token,其余全部踢掉。这套能力几乎完全建立在会话查询之上,没有可靠的话,后台安全功能就无从谈起。
如果想让“在线状态”更实时,还可以在登录时往 Redis 里维护 LoginId 与 token 列表的映射,登出时移除对应项。这不属于 SaToken 必须做的事,但配合 Session 查询一起用,能让在线管理功能更符合实际需要。
我把这套认证链路在“某内部运营后台”里完整跑通后,最大的感受是:SaToken 提供的是会话骨架,密码策略和会话数据的组织方式才是项目真正需要花心思设计的地方。BCrypt 加密落库是底线,会话查询是日常使用的入口,两者整合在同一个 AuthService 里,后续加多端登录限制、会话审计、在线用户列表都变得顺理成章。如果你正准备给项目搭登录体系,建议一开始就把这条链路设计完整——密码只负责验证身份,会话负责承载状态,两边不要割裂着做。真到排查线上问题时,你会发现这套整合带来的收益远超写代码时花的那点功夫。