练习题与复盘
这些练习围绕工作中高频出现的认证需求和故障。建议先独立完成,再回看前面的教程。
使用方式
每道题至少写出:
- 你的设计或代码。
- 为什么这样做。
- 失败时返回什么状态码。
- 存在哪些安全边界。
- 如何测试成功和失败路径。
练习 1:区分四个概念
分别说明 Base64URL、SHA-256、Argon2id、HS256 的目的,并回答:
- 哪些可以还原原文?
- 哪些用于密码?
- 哪些用于 JWT?
- 为什么 JWT Payload 能被读取但不能随意修改?
验收重点:不要把编码、加密、哈希和签名混为一谈。
练习 2:实现注册接口
实现 POST /register:
- 用户名 3~64 个字符,统一小写。
- 密码 12~128 个字符。
- 用户名不能重复。
- 使用
argon2.hash()和argon2.argon2id。 - 响应中不得出现明文密码或密码哈希。
进一步思考:两个相同用户名同时注册,为什么“先查询再写入”仍不够?MySQL 应增加什么约束?
练习 3:实现登录接口
实现 POST /login:
- 使用
argon2.verify()。 - 账号不存在和密码错误返回同一提示。
- 成功后创建独立会话。
- 返回 15 分钟 Access Token 和 7 天 Refresh Token。
测试:正确密码、错误密码、不存在账号、异常哈希字符串。
练习 4:升级旧密码哈希
在密码验证成功后调用 argon2.needsRehash():
- 需要升级时重新哈希。
- 更新数据库。
- 不能在验证失败时升级。
思考:为什么升级时不需要用户再次提供密码?
练习 5:实验 decode() 与 verify()
- 使用
jwt.sign()生成 Token。 - 使用
jwt.decode()读取 Payload。 - 手动修改 Payload 中的角色并重新进行 Base64URL 编码。
- 分别调用
decode()和verify()。
记录结果并解释为什么 decode() 不能用于身份认证。
练习 6:expiresIn 单位陷阱
分别签发:
{ expiresIn: 120 }
{ expiresIn: "120" }
{ expiresIn: "120s" }
{ expiresIn: "2m" }
使用 decode() 仅用于实验查看 iat 和 exp,计算每种 Token 的有效期。解释无单位字符串的风险。
练习 7:Bearer Token 解析器
编写 readBearerToken() 并测试:
Authorization: Bearer abc
Authorization: bearer abc
Authorization: Bearer
Authorization: Basic abc
Authorization: Bearer abc extra
Authorization: Bearer abc
定义每个输入应该返回 Token 还是 null。
练习 8:认证中间件
实现中间件并满足:
- 缺少 Token 返回
401。 - Token 过期返回
401和稳定错误码。 - 限制
algorithms: ["HS256"]。 - 检查
issuer和audience。 - 自定义 Payload 使用类型守卫检查。
- 未预期异常交给 Express 错误中间件。
禁止使用 any 和 jwt.decode() 进行认证。
练习 9:认证与授权
实现:
/profile:任意已登录用户可以访问。/admin/users:只有管理员可以访问。
分别设计未登录、Token 无效、普通用户访问管理员接口时的状态码,并解释 401 与 403。
练习 10:Refresh Token Rotation
实现 /token/refresh:
- 数据库只保存 Refresh Token 的 SHA-256 摘要。
- 检查会话未撤销、未过期。
- 每次刷新签发新 Access Token 和 Refresh Token。
- 旧 Refresh Token 立即失效。
测试连续使用旧 Token 两次时,第二次必须失败。
练习 11:处理并发刷新
假设同一个 Refresh Token 同时发出两个请求:
- 解释内存实现为什么可能都成功。
- 设计数据库条件更新或事务。
- 说明如何根据受影响行数判断轮换是否成功。
- 讨论客户端遇到两个并发
401时如何避免同时刷新。
这是前后端分离系统中非常高频的问题。
练习 12:单设备和全部设备退出
实现:
/logout:撤销 Refresh Token 对应会话。/logout-all:撤销用户全部会话。
回答:为什么退出后旧 Access Token 可能仍可使用?怎样实现即时失效,代价是什么?
练习 13:修改密码后的会话策略
设计 POST /change-password:
- 验证旧密码。
- 哈希新密码。
- 撤销其他设备会话。
- 当前设备是保留还是撤销,由你选择并说明原因。
- 防止弱密码和重复提交。
练习 14:排查“总是返回 401”
列出并逐项排查:
- 请求头格式错误。
- 使用了错误 Secret。
issuer不一致。audience不一致。- 签发用 HS256,验证只允许 RS256。
- Token 已过期。
- 服务器时钟明显不同步。
- Payload 缺少
tokenType或sub。
要求日志不能打印完整 Token。
练习 15:CommonJS 与导入问题
结合当前 tsconfig.json 回答:
- 为什么源码能使用
import jwt from "jsonwebtoken"? module: "commonjs"对运行输出有什么影响?esModuleInterop在这里解决什么兼容问题?- 为什么
argon2不需要@types/argon2? - 若切换到真正 ESM,需要同步检查哪些配置和启动方式?
练习 16:设计数据库表
为 users 和 auth_sessions 编写 MySQL DDL,至少包含:
- 主键。
- 用户名唯一约束。
- 用户与会话外键。
- Refresh Token 摘要唯一约束。
- 查询用户全部会话所需索引。
- 查询并清理过期会话所需索引。
解释 password_hash 和 refresh_token_hash 的长度选择。
练习 17:安全评审
评审下面的代码,找出尽可能多的问题:
app.post("/login", async (req, res) => {
const user = users.find((item) => item.username === req.body.username);
if (!user) {
return res.status(404).json({ message: "账号不存在" });
}
if (user.password !== req.body.password) {
return res.status(401).json({ message: "密码错误" });
}
const token = jwt.sign(
{
...user,
password: req.body.password,
},
"123456"
);
console.log("login", req.body, token);
res.json({ token });
});
至少从密码存储、信息泄露、JWT Payload、Secret、过期时间、日志、输入校验、账号枚举和限流角度分析。
练习 18:生产化清单
把内存案例升级为 MySQL 版本,并补充:
- 事务与唯一约束。
- 登录和刷新限流。
- Refresh Token 安全 Cookie。
- CORS 与 CSRF 策略。
- 结构化日志脱敏。
- 会话清理任务。
- Secret 轮换设计。
- 自动化测试。
不要一次引入复杂认证框架。先让每个安全决策都能被自己解释,再考虑抽象。
复盘问题
完成后,尝试不看文档回答:
argon2.verify()和jwt.verify()同名,但分别在验证什么?- 为什么 JWT 签名不是加密?
- 为什么 Access Token 应短期,而 Refresh Token 需要服务端状态?
- 为什么高熵 Refresh Token 可以使用 SHA-256 摘要,而密码不可以?
- 为什么退出登录后 Access Token 不一定立即失效?
- 为什么
verify()成功后仍要检查 Payload 字段类型? - 为什么生产刷新逻辑必须考虑并发?