秒杀系统高并发优化实战(C++ / Drogon):3.2 用户登录接口:密码比对 + 自实现 HS256 签发 + Redis 可吊销会话
注册接口建好了"身份",这一篇让身份"进门"——登录接口。它要做三件事:比对密码、签发 JWT、把会话写进 Redis。前两件是安全,第三件是"可吊销"——没有它,JWT 一旦签发就只能在 exp 到点后自己失效,改密码/封号都拦不住旧 Token,等于门没锁。
配套代码:
src/service/UserService.cc(login)、src/service/Jwt.{h,cc}、src/service/SessionStore.{h,cc}、src/controllers/UserController.cc(login)。延续 3.1 的"服务层只做业务、控制器只做协议转换"分层。
一、登录接口:是什么、坑在哪、本质一句话
是什么:登录 = 用"手机号 + 密码"证明你是你,成功后发一个不可伪造的凭证(JWT),并在 Redis 留下一条可吊销的会话记录,让服务端随时能把这把钥匙作废。
坑在哪:
- 密码明文比对的时序攻击:用
==比对哈希,短路时机可被计时观测,攻击者能逐步推出哈希前缀。 - "用户不存在"被枚举:手机号不存在时如果只回"用户不存在"、不去记失败,攻击者就能用返回差异探出哪些手机号注册过。
- JWT 算法混淆:校验时不强制
alg,攻击者把alg改成none或 RS256→HS256 就能伪造 Token。 - 签发后无法吊销:纯无状态 JWT 在封号/改密后旧 Token 仍有效。
本质一句话:登录的本质是"验明正身 + 发可吊销的钥匙"——密码比对走常量时间,JWT 用自实现 HS256 且校验时锁死算法,会话状态交给 Redis 做权威态。
二、登录主流程:先问锁定,再查库,再比对
UserService::login 的顺序是经过安全权衡的:
- 先问锁定状态:被锁期间连密码都不该去查、去算——省一次 DB 查询 + 一次 PBKDF2(约 30~60ms CPU)。
- 查用户:手机号不存在也照常记一次失败(防账号枚举),返回
USER_NOT_FOUND。 - 常量时间比对哈希:用
constantTimeEquals而非==。 - 成功清失败计数、签发 JWT、写会话。
1 | // src/service/UserService.cc(节选) |
三、自实现 HS256:三个动作,各十几行
Jwt 的 sign 就是把 header/payload 各自 base64url 后拼起来,对前两段做 HMAC-SHA256,再 base64url 一次。verify 反过来:先解 header 锁死 alg==HS256,再验签(常量时间),最后才解 payload 查 iss/exp——顺序不能反,否则 alg:none 伪造本会绕过验签。
1 | // src/service/Jwt.cc(节选) |
安全红线:① 校验强制
alg==HS256,否则alg:none可伪造任意 Token,或 RS256→HS256 算法混淆;② 签名比对走constantTimeEquals;③ 过期只看exp,服务端时钟为准。
四、可吊销会话:Redis 权威态
登录成功 SessionStore::save 写 SETEX sess:{jti} ttl userId,TTL 与 JWT 的 exp 对齐——Token 过期那一刻 Redis key 也自然消失,不留垃圾。鉴权 exists 失败即拒绝(fail-close:宁可让已登录用户重登,也不能在 Redis 抖动时放行进被封禁的会话);吊销 revoke = DEL 一行命令。
1 | // src/service/SessionStore.cc |
五、控制器与错误码
登录控制器从 body 取 phone/password,把业务拒绝码映射成 409、系统错误映射成 500(与 3.1 同一套 statusForFailure 规则)。
1 | // src/controllers/UserController.cc(login 节选) |
登录 / 注册的错误码(节选):
| HTTP | msg | 含义 |
|---|---|---|
| 200 | success |
成功(data.token 为 JWT) |
| 409 | USER_NOT_FOUND |
手机号不存在(为防账号枚举,同样计一次失败) |
| 409 | WRONG_PASSWORD:<已试次数> |
密码错,明确告知还能试几次 |
| 409 | ACCOUNT_LOCKED:<剩余秒数> |
连续失败达 max_fail,临时锁定 |
| 409 | ACCOUNT_DISABLED |
账号被禁用(user.status=0) |
| 500 | DB_ERROR:* / REDIS_ERROR:* / HASH_FAILED |
系统故障,可重试 |
| 503 | * unavailable (redis not connected) |
Redis 未起,登录模块整体不可用 |
六、可运行验证
1 | # 登录 → 拿 token |
功能抉择(本篇核心权衡)
① 为什么密码比对必须走常量时间?== / memcmp 在首个不匹配字节处就返回,比较耗时随"前缀匹配长度"变化。攻击者用精心构造的 Token/密码、配合统计计时,能逐步推出哈希前缀——这就是计时攻击。PBKDF2 已经把"正向算"变慢了,比对这一步若用 == 就会在"反向推"上开口子,constantTimeEquals 让比较无论匹配多少位都走完整循环。
② 为什么 JWT 校验要先锁死 alg 再验签?
经典 JWT 漏洞是 alg:none(服务端不验签直接信任 payload)和算法混淆(把 RS256 的公钥当 HMAC 密钥去验 HS256)。把 alg==HS256 的检查放在验签之前,让"不受支持的算法"这条拒绝路径最短、最不可绕过——即使验签逻辑有疏漏,算法这关先挡住伪造。
③ 为什么会话权威态放 Redis 而不是进程内存?
进程内存的 sess 只对单实例有效,多实例部署时"吊销"只在一台上生效,旧 Token 在别的实例仍可用。Redis 是共享单一态,多实例语义不变——这正是秒杀要上多实例的前提。
④ 为什么签发后仍要在 Redis 留一条,而不是纯无状态 JWT?
纯无状态 JWT 在封号/改密后旧 Token 仍有效到 exp,风控形同虚设。Redis 一条 sess:{jti} 让"吊销 = 删 key"一行命令解决"无法踢人"的硬伤,代价只是每次鉴权多一次 EXISTS(Redis 单实例轻松扛百万 QPS)。
小结
- 登录 = 验明正身 + 发可吊销钥匙:密码比对走
constantTimeEquals,绝不用==。 - JWT 自实现 HS256,校验时先锁死
alg==HS256再常量时间验签,防none伪造与算法混淆。 - 会话权威态在 Redis(
sess:{jti},SETEX 对齐 exp):鉴权exists失败即拒(fail-close),吊销DEL一行解决。 - 用户不存在也记失败、明确告知剩余尝试次数——防账号枚举 + 给攻击者明确信号。
- 哈希计算丢进工作线程,不阻塞 IO 线程上的秒杀请求。🐾
配套源码
本文对应代码已开源:Hespethorn/seckill-cpp
C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:
- 阶段一(直打数据库):QPS≈440,0 超卖
- 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS
如果对你有帮助,欢迎 star ⭐

