读完这篇,你能做什么
能说明登录态的完整写入、读取、续期和清理链路,并检查匿名接口的行为。
学习理由、准备要求与知识点
为什么学它
登录贯穿令牌生成、用户 DTO、过期与线程清理,任何一段类型或生命周期不一致都会出错。
先读这些内容
知识点清单
- 验证码与限流
- 随机登录令牌
UserDTO- Hash 序列化
- 整体 TTL
- 字段类型转换
- 刷新与鉴权拦截
- 上下文清理
运行前先确认点评应用通用教学流程,不声称采用实际项目源码。Redis 7.4、Java 17;ThreadLocal 源码固定 OpenJDK 17.0.16+8。算法只模拟验证码状态,不发送短信,不生成生产令牌。
01先弄懂它是什么
短信登录里有三个东西,不要混在一起:验证码完成这一次身份核验,令牌把后续请求关联到会话,用户 DTO 保存后续业务需要的少量身份信息。手机号不能直接充当难以猜测的会话令牌,完整用户实体也不宜直接当会话内容。分清三者,才能分别规定有效期、保存字段和读取类型。
完整流程从规范手机号、限制发送频率和设置验证码有效期开始。核验成功后消费验证码,再查找或创建用户,并生成不可预测的会话令牌。手机号唯一约束能防止并发建出重复用户,却不能替代验证码只能用一次的要求。若验证码已消费、后续创建失败,应明确重新验证或受控恢复流程,不能让同一验证码无限重用。
Redis 会话解决多实例共享身份状态,ThreadLocal 解决当前线程访问身份的便利性。前者可存活几十分钟,后者应局限在一次请求处理范围内。服务器线程会复用,请求结束未清理时,下一个匿名请求可能看到上个用户。即使 Redis 中的令牌已经删除,线程里残留的 DTO 也不会自动消失。
文中这几个词,先认一认
- UserDTO
- 请求身份所需的少量字段组成的对象,例如 id、
nickName、icon;它不是数据库 User 实体。 - 单次消费
- 把验证码核验成功和作废作为一次原子状态变化,重复提交时不能再验证成功。
- 滑动有效期
- 会话有效访问时延长闲置有效期;通常仍应另设从登录起计算的最长有效时间。
02看看它怎么工作
写入类型必须沿整条链保持一致:User 实体先显式转换为 UserDTO,再编码为 Redis Hash 的字符串字段;读取时验证 id 格式和范围,再构造新的 UserDTO。User 不是 UserDTO 的实例时,强制类型转换不会帮你转换属性。也不能假定一个 Map 中的 id 已经是 Long,Redis 序列化器和客户端会决定实际表示。
保存会话时,身份字段和有效期都要写成功,不能只保存身份却漏掉期限。Hash 场景可以把 HSET 和 EXPIRE 放进 MULTI/EXEC,并逐项检查返回值;其中一项运行失败时,Redis 不会自动撤销另一项,所以仍要处理只完成一部分的情况。有效的已登录访问,包括相关公开页面上的访问,可以按规则延长会话期限;能否进入受保护接口,再由单独的权限检查决定。无效令牌不能因为被反复提交就不断续期。
请求开始时要放入本次正确的身份,结束时在 finally 或可靠的请求完成钩子里调用 remove。使用异步 Servlet、线程池或 CompletableFuture 后,后续代码可能换到另一个线程;普通 ThreadLocal 不会自动把身份带过去。
需要时应显式传递一份不可变的身份信息,或使用框架支持的传递方式,并让工作线程自己完成清理。下面先用同步方法演示验证码的检查与消费不可分开,再考虑怎样用 Redis Lua 或其他共享存储实现同样规则。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["手机号与验证码"] --> B["原子校验 消费"]
B --> C["查找或创建 User"]
C --> D["显式构造 UserDTO"]
D --> E["Redis 字符串字段及期限"]
E --> F["请求读取 解析 UserDTO"]
F --> G["对象授权与业务处理"]
G --> H["请求结束 remove"]按顺序看,每一步做了什么
- 对验证码发送和验证建立频率、失败次数与有效期规则。
- 原子完成匹配检查与成功消费,再创建会话。
- 显式处理 User、DTO、Redis 字段之间的类型转换。
- 在每条完成、异常和异步路径验证身份传播与清理。
03对照实现代码
OpenJDK jdk17u jdk-17.0.16+8 · ThreadLocal#set;ThreadLocal#remove;ThreadLocal.ThreadLocalMap#remove
以下是原创同步请求边界示例,展示 DTO 解码与 finally 清理;不是项目 UserHolder 或 JDK 源码节选,不适用于未经适配的异步请求生命周期。
import java.util.Map;
import java.util.concurrent.Callable;
class SourceModel {
record UserDTO(long id, String nickName, String icon) {}
private static final ThreadLocal<UserDTO> CURRENT = new ThreadLocal<>();
static UserDTO decode(Map<String,String> fields) {
String raw = fields.get("id");
if (raw == null || !raw.matches("[0-9]+")) throw new IllegalArgumentException("id");
long id = Long.parseLong(raw); // 显式解析,溢出和非法字段不形成已登录身份
if (id <= 0) throw new IllegalArgumentException("id");
return new UserDTO(id, fields.getOrDefault("nickName",""), fields.getOrDefault("icon",""));
}
static <T> T request(UserDTO user, Callable<T> business) throws Exception {
CURRENT.remove(); // 同步顶层请求入口先清除可能残留的旧身份
try {
if (user != null) CURRENT.set(user);
return business.call(); // 业务仍应执行对象归属和权限检查
} finally {
CURRENT.remove(); // 正常返回和异常离开都清理当前线程
}
}
}真实 set 和 remove 都从 Thread.currentThread() 获取 ThreadLocalMap,因此操作的是调用线程的身份副本,而不是整个进程中的所有请求。
remove 找到当前 ThreadLocal 对应条目后清除并整理映射;它不向 Redis 发送删除命令,也不会撤销其他线程已取得的 DTO。
ThreadLocalMap 的键用了弱引用,也不表示请求一结束,里面保存的用户对象就会马上清空。服务器线程可能长期存活;漏掉 remove,仍可能让旧对象保存太久,甚至让后来的请求读到上次身份。弱引用不能代替明确的请求结束清理。
教学 request 只处理同步的最外层请求,所以结束时直接清理,不恢复旧值。若还有嵌套身份切换或异步分派,就要另外规定进入、退出和传递方式,不能原样套用。
代码先调用谁,再调用谁
- Redis 字段解码为
UserDTO - 当前请求线程 set
- 业务读取并授权
- 正常或异常结束
- 当前请求线程 remove
04动手写一个例子
验证码有效期、失败预算与单次消费
以主体与用途组成验证码键。服务端发行新的六位教学验证码时替换旧值;验证在到期前成功一次后删除,失败最多消耗三次机会。所有状态变化在同一个进程内原子执行;真实多实例需等价共享协议。
- 输入 1
有效码123456;先错误一次,再正确一次,再重放- 输出 1
false、true、false
- 输入 2
到期点100,验证时刻100- 输出 2
false
用 Key(subject,purpose) 隔离同一主体的登录与其他用途,避免只按手机号存一个通用验证码。
校验和删除放在一个同步方法内;匹配成功时立即删除,两个并发提交只有一个能观察到可消费状态。
失败则减少剩余次数,耗尽和过期都移除记录。对外统一失败结果,内部监控再分类,避免接口泄露过多账号状态。
为什么这样写是对的同一已发行挑战最多返回一次成功;已过期或耗尽预算的挑战不再通过;不同用途互不消费。
六位比较长度固定,哈希表更新平均常数;n 为仍保存的挑战数。真实系统还需定期清理无人访问的过期记录。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.concurrent.*;
public class Main {
record Key(String subject, String purpose) {}
record Challenge(String code, long expiresAt, int attemptsLeft) {}
static final class OtpStore {
private final Map<Key,Challenge> records = new HashMap<>();
synchronized void issue(Key key, String code, long expiresAt) {
if (key.subject()==null || key.subject().isBlank() || key.purpose()==null ||
key.purpose().isBlank() || code==null || !code.matches("[0-9]{6}") || expiresAt<=0)
throw new IllegalArgumentException("challenge");
records.put(key,new Challenge(code,expiresAt,3)); // 新发行使同用途旧码失效
}
synchronized boolean verify(Key key, String supplied, long now) {
if (now<0) throw new IllegalArgumentException("time");
Challenge c=records.get(key);
if (c==null) return false;
if (now>=c.expiresAt()) { records.remove(key); return false; } // 到期边界不再有效
boolean format=supplied!=null && supplied.matches("[0-9]{6}");
boolean equal=format && MessageDigest.isEqual(
c.code().getBytes(StandardCharsets.US_ASCII),
supplied.getBytes(StandardCharsets.US_ASCII));
if (equal) { records.remove(key); return true; } // 匹配与消费在同一原子边界内
if (c.attemptsLeft()==1) records.remove(key);
else records.put(key,new Challenge(c.code(),c.expiresAt(),c.attemptsLeft()-1));
return false; // 格式错误也消耗现存挑战的验证预算
}
}
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
public static void main(String[] args) throws Exception {
OtpStore s=new OtpStore();
Key login=new Key("account-1","LOGIN"), reset=new Key("account-1","RESET");
s.issue(login,"123456",100);
check(!s.verify(login,"000000",1) && s.verify(login,"123456",2));
check(!s.verify(login,"123456",3));
s.issue(login,"123456",100);
check(!s.verify(login,"123456",100));
s.issue(login,"123456",200);
check(!s.verify(login,"x",101) && !s.verify(login,"000000",102) &&
!s.verify(login,"111111",103) && !s.verify(login,"123456",104));
s.issue(login,"123456",200); s.issue(login,"654321",200);
check(!s.verify(login,"123456",110) && s.verify(login,"654321",111));
s.issue(login,"123456",200); s.issue(reset,"654321",200);
check(s.verify(reset,"654321",112) && s.verify(login,"123456",112));
check(!s.verify(new Key("missing","LOGIN"),"123456",113));
s.issue(login,"123456",200);
ExecutorService pool=Executors.newFixedThreadPool(2);
CountDownLatch start=new CountDownLatch(1);
try {
Callable<Boolean> call=() -> { start.await(); return s.verify(login,"123456",120); };
Future<Boolean> a=pool.submit(call), b=pool.submit(call);
start.countDown();
check((a.get()?1:0)+(b.get()?1:0)==1); // 并发重放只能消费成功一次
} finally { pool.shutdownNow(); }
System.out.println("8 OTP lifecycle checks passed");
}
}这些特殊情况也要试一试
- 验证码为固定教学输入,生产发行必须采用合适安全随机源及发送限制。
- 本模型明文保存在内存,实际存储需结合凭证威胁模型、访问控制与保留期设计。
- 验证码单次通过不表示会话已经建立,后续失败需要明确用户流程。
进阶选读 · 生产拓展:跨实例消费与会话发行的失败恢复
场景多个应用实例验证同一验证码,且验证成功后到创建会话之间可能崩溃。
做法用共享存储的原子脚本实现校验、失败预算与消费;对后续流程设置短期、用途受限的认证事务标识,或明确重新验证规则。不要把已消费验证码重新放回。会话发行和恢复接口都应防重复与越权。
复杂度每次挑战状态转换可以保持固定次数存储操作;恢复记录空间随保留窗口增长。
好处与代价更平滑恢复会增加凭证种类与状态机复杂度,简单业务可以采用清晰的重新验证体验。
05项目里怎么用
验证码保护要覆盖发送和验证两个入口。发送应按主体、来源和业务额度限制,重新发送不能成为无限重置猜测预算的通道;验证需要总请求预算与风险控制。错误响应和日志不得回显真实验证码或完整会话令牌。短信只能证明当时对该通信渠道的控制,重要操作还要考虑进一步验证策略。
令牌使用安全随机源并通过 TLS 传输。Cookie 或请求头的存放方案分别需要评估浏览器脚本访问、CSRF 与泄露风险。退出应使服务端会话失效;强制下线和账号禁用需要说明何时作用于已恢复的请求身份。长时间滑动续期还应结合绝对期限,避免凭证在不断访问中无限存活。
测试沿写入和读取两端建立闭环:实体转 DTO,DTO 编码为 Hash,Hash 解码并校验,再在请求结束清理。至少模拟成功、过期、损坏 id、匿名请求紧随登录请求,以及业务抛异常。异步场景应在实际任务线程验证上下文,而不能因为主请求线程 remove 了就认为全部工作线程已清理。
容易踩的坑
- 登录存 User,读取时直接强转
UserDTO。 - 校验验证码与删除分成两次无协调请求,允许并发重复通过。
- 把
ThreadLocal弱键机制当作可靠的请求清理。 - 每次访问都续期,包括无效令牌或无限延长的旧会话。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 最小 DTO 会话 | 类型和暴露字段边界清楚 | 资料变化可能需要更新或重新加载 | 共享登录态与请求身份 |
| 滑动期限加绝对期限 | 兼顾活跃体验和凭证最长寿命 | 需要保存发行时间并处理续期 | 长期使用的浏览器会话 |
| 显式传递异步身份 | 跨线程行为可追踪 | 需要维护方法参数和上下文范围 | 线程池或异步任务 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01验证码、令牌、DTO分别有什么职责?
展开参考答案与推导
验证码完成一次验证,令牌关联后续会话,DTO提供业务需要的身份字段。
02Redis会话过期会自动清空ThreadLocal吗?
展开参考答案与推导
不会。二者位置与生命周期不同,请求线程必须自行清理。
03为什么不能把User强转为UserDTO?
展开参考答案与推导
强转只检查对象的运行时类型,不复制属性;应构造目标对象并显式转换字段。
换个情况,还会处理吗
01线程A处理登录用户后又处理匿名请求,如何避免串身份?
展开参考答案与推导
可靠地在请求结束remove,并在入口建立明确上下文;匿名请求不能继承上一次线程值。
02验证码已消费但会话写入失败,能重新塞回验证码吗?
展开参考答案与推导
不宜。可能重新启用已使用凭证,应采用明确重新验证或受控认证事务恢复。
03异步任务读取不到UserHolder,应该怎么做?
展开参考答案与推导
识别线程边界,显式传递最小身份快照或使用框架上下文机制,并在工作线程结束清理。
写代码试一试
01到期点100,在100验证正确码是否成功?
- 输入 / 条件
expiresAt=100,now=100- 输出 / 预期
false
提示 有效区间右端不包含到期点。
展开参考答案与推导
使用now>=expiresAt判定失效,并清除挑战。
02两个并发正确提交如何只成功一个?
- 输入 / 条件
同一个挑战并发两次验证- 输出 / 预期
一true一false
提示 检查和消费同一原子边界。
展开参考答案与推导
第一个成功删除记录,第二个无法再取得有效挑战;跨实例需要共享原子存储协议。
03Redis字段id为abc时能构造已登录DTO吗?
- 输入 / 条件
id=abc- 输出 / 预期
拒绝身份解码
提示 不能用默认0伪装成功。
展开参考答案与推导
验证数字格式、Long范围和业务正数条件;失败按无效会话处理并记录脱敏诊断。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能说明登录态的完整写入、读取、续期和清理链路,并检查匿名接口的行为。
本篇重点加练
会话寿命与刷新、鉴权次序共同决定持续在线和及时失效能否同时成立。
- 整体 TTL
- 刷新与鉴权拦截
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01面试场景
短信发送被同一设备切换手机号连续调用,而登录请求重复到达。设计发送与验证两阶段的限制口径,并界定验证码和登录令牌的可记录信息。
- 提交内容
- 接口规则与滥用测试表
- 怎样算完成
- 分别覆盖发送和验证
- 包含重复提交及敏感信息检查
练到的知识点验证码与限流 · 随机登录令牌
- 作业 02代码推演
为
UserDTO定义仅含 id、nickName、icon 的 Hash 协议。给出字段缺失、额外字段、空字符串三组输入,逐字段分析恢复结果与拒绝规则。- 提交内容
- 序列化契约与逐项推演
- 怎样算完成
- 区分可选字段与必需字段
- 禁止凭 Java 类型名猜测转换行为
练到的知识点UserDTO · Hash 序列化
- 作业 03设计评审
登录用户访问公开页、受保护页和不存在的路径时分别经过哪些拦截步骤?把异常、提前返回及异步派发列入上下文生命周期评审。
- 提交内容
- 路径矩阵与时序图
- 怎样算完成
- 写清刷新触发条件
- 逐个标出线程上下文的退出责任
练到的知识点刷新与鉴权拦截 · 上下文清理
- 作业 04代码推演
有效会话还剩很短寿命,两个请求先后完成读取与刷新,其中一个请求到达受保护接口较晚。定义每步使用的时间及失效判据,画出可能的观察窗口。
- 提交内容
- 会话时间线
- 怎样算完成
- 读取、刷新、授权时点分别记录
- 包含过期边界相等的情况
练到的知识点整体 TTL · 刷新与鉴权拦截
综合应用
- 作业 05排错实验
在独立本地 Redis 中写入含超大 id、前导空格 id 和正常 id 的会话 Hash,再执行一次字段更新;观察 DTO 恢复与整个键 TTL 的变化。
- 提交内容
- 最小程序、命令与观测表
- 怎样算完成
- 记录类型转换失败的处理
- 记录更新前后 TTL 和测量时刻
练到的知识点整体 TTL · 字段类型转换
- 作业 06编程实践
实现仅绑定回环地址的会话恢复演示,使用单工作线程交替处理有效、损坏、过期会话和匿名请求;用可控时钟或短 TTL 记录行为。
- 提交内容
- Java 源码与可重放请求集
- 怎样算完成
- 响应不泄露会话令牌
- 所有分支记录身份来源和资源释放证据
练到的知识点UserDTO · Hash 序列化 · 整体 TTL · 字段类型转换 · 刷新与鉴权拦截 · 上下文清理
- 作业 07设计评审
客户端长期后台轮询公开资源,产品却要求用户无主动操作一小时后退出。评审滑动有效期如何识别活动,并定义绝对寿命与空闲寿命的口径。
- 提交内容
- 有效期契约
- 怎样算完成
- 区分后台流量和用户活动
- 说明服务端采用的可信时间来源
练到的知识点整体 TTL
- 作业 08面试场景
刷新拦截器依赖 Redis,鉴权拦截器读取恢复出的 DTO。Redis 响应迟到而请求已被取消时,评审哪些后续动作仍可能发生及其可观测性。
- 提交内容
- 取消后的动作清单
- 怎样算完成
- 区分响应生命周期和服务端执行
- 指出可确认与未知状态
练到的知识点刷新与鉴权拦截
- 作业 09排错实验
在独立本地 Redis 与最小拦截器桩中,分别安排只访问公开路径、连续受保护请求及请求取消,记录会话最后访问时间和真实到期时间。
- 提交内容
- 本地程序与计时记录
- 怎样算完成
- 固定测试时间范围
- 对测量精度及桩未验证的 Web 行为作说明
练到的知识点整体 TTL · 刷新与鉴权拦截
进阶挑战 · 掌握前置后选做
- 作业 10面试场景
产品提出“用户最多同时登录两台设备,退出单台不影响另一台”。说明现有单令牌模型需要哪些状态,评估续期、并发登录和退出请求乱序。
- 提交内容
- 状态图与故障测试计划
- 怎样算完成
- 覆盖第三台登录的竞争
- 声明撤销的生效时间范围,以及过期数据的清理规则
练到的知识点验证码与限流 · 随机登录令牌 · 整体 TTL · 刷新与鉴权拦截
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 条件变化
将会话改为“绝对寿命 8 小时且管理员撤销须在 10 秒内生效”,同时允许 Redis 暂时不可用。建立需求冲突表并提出可验证的服务承诺。
- 提交内容
- 约束表与验收提案
- 怎样算完成
- 明确不可用期间的业务决策
- 不能同时默认所有目标无条件满足
练到的知识点整体 TTL · 刷新与鉴权拦截
- 作业 12思维拓展 · 开放探究
研究“收到有效令牌的请求就应自动延长登录”这一规则是否适用于共享终端与无人值守设备,为两类场景提出不同的可检验假设。
- 提交内容
- 研究问题与对照方案
- 怎样算完成
- 至少包含一个安全与便利性的冲突
- 不用真实账户实验
练到的知识点整体 TTL · 刷新与鉴权拦截
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
Redis:Hashes (新标签页)↗
阅读字段读写与对象建模,确定整体 TTL 和部分更新的设计边界。
- 实践参考文档英文
Redis:EXPIRE (新标签页)↗
阅读 TTL、覆盖写入和更新命令的交互,检查缓存是否会意外永不过期。
- 深入实践指南英文
OWASP:Session Management (新标签页)↗
阅读会话令牌、Cookie 属性和会话更新,避免登录状态泄露与固定会话。
- 深入参考文档英文
Java 17:ThreadLocal (新标签页)↗
阅读线程绑定值和 remove,在复用线程中清理请求上下文。
- 深入参考文档英文
OpenJDK17 ThreadLocal源码 (新标签页)↗
核对当前线程映射和remove的职责。
- 深入参考文档英文
Redis事务 (新标签页)↗
避免把MULTI
/EXEC误认为任意错误自动回滚。 - 深入参考文档英文
OWASP认证指南 (新标签页)↗
设计频率限制和认证失败处理。