注册接口建好了"身份",这一篇让身份"进门"——登录接口。它要做三件事:比对密码、签发 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 的顺序是经过安全权衡的:

  1. 先问锁定状态:被锁期间连密码都不该去查、去算——省一次 DB 查询 + 一次 PBKDF2(约 30~60ms CPU)。
  2. 查用户:手机号不存在也照常记一次失败(防账号枚举),返回 USER_NOT_FOUND。
  3. 常量时间比对哈希:用 constantTimeEquals 而非 ==。
  4. 成功清失败计数、签发 JWT、写会话。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// src/service/UserService.cc(节选)
void UserService::login(const std::string &phone, const std::string &password,
std::function<void(bool, const std::string &, const std::string &)> &&cb) {
if (!SmsService::isValidPhone(phone)) { cb(false, "", "INVALID_PHONE"); return; }

guard_->checkLocked(phone, // ① 先问锁定
[this, phone, password, cb](bool locked, int remain) {
if (locked) { cb(false, "", "ACCOUNT_LOCKED:" + std::to_string(remain)); return; }
db_->execSqlAsync("SELECT id,password_hash,salt,status FROM user WHERE phone=?",
[this, phone, password, cb](const drogon::orm::Result &r) {
if (r.size() == 0) { // ② 用户不存在也记失败(防枚举)
guard_->onFailure(phone, [cb](const FailResult &) { cb(false,"","USER_NOT_FOUND"); });
return;
}
const auto row = r[0];
if (row["status"].as<int>() != 1) { cb(false, "", "ACCOUNT_DISABLED"); return; }
asyncHashPassword(password, row["salt"].as<std::string>(), // 工作线程跑 PBKDF2
[this, phone, userId = row["id"].as<int64_t>(), stored = row["password_hash"].as<std::string>(), cb](std::string computed) {
if (!constantTimeEquals(computed, stored)) { // ③ 常量时间比对
guard_->onFailure(phone, [cb](const FailResult &fr) {
cb(false, "", fr.kind == FailResult::Kind::Counting
? "WRONG_PASSWORD:" + std::to_string(fr.failCount)
: "ACCOUNT_LOCKED:" + std::to_string(fr.lockSeconds));
});
return;
}
guard_->onSuccess(phone, [] {}); // ④ 清失败计数
const std::string jti = genJti();
const std::string token = jwt_->sign(userId, jti); // ⑤ 签发
sessions_->save(jti, userId, // ⑥ 写可吊销会话
[cb, token](bool ok) {
if (!ok) { cb(false, "", "REDIS_ERROR:session save failed"); return; }
cb(true, token, "OK");
});
});
},
[cb, phone](const std::exception_ptr &e) { cb(false, "", "DB_ERROR:" + describe(e)); },
phone);
});
}

三、自实现 HS256:三个动作,各十几行

Jwt 的 sign 就是把 header/payload 各自 base64url 后拼起来,对前两段做 HMAC-SHA256,再 base64url 一次。verify 反过来:先解 header 锁死 alg==HS256,再验签(常量时间),最后才解 payload 查 iss/exp——顺序不能反,否则 alg:none 伪造本会绕过验签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/service/Jwt.cc(节选)
std::string Jwt::sign(int64_t userId, const std::string &jti, int64_t nowSeconds) const {
Json::Value header; header["alg"] = "HS256"; header["typ"] = "JWT";
Json::Value payload;
payload["iss"] = issuer_; payload["sub"] = std::to_string(userId);
payload["jti"] = jti;
payload["iat"] = Json::Int64(now); payload["exp"] = Json::Int64(now + ttlSeconds_);
std::string input = base64UrlEncode(jsonToString(header)) + "." + base64UrlEncode(jsonToString(payload));
return input + "." + hmacToBase64Url(secret_, input); // HMAC-SHA256(前两段)
}

bool Jwt::verify(const std::string &token, JwtClaims &out, int64_t nowSeconds) const {
// 1) 三段结构 → 2) 解 header 判 alg==HS256(先于验签)→ 3) 常量时间验签
// 4) 解 payload 查 iss 与 exp(不设 leeway,同进程同时钟)
if (header["alg"].asString() != "HS256") return false;
if (!constantTimeEquals(hmacSha256(secret_, signingInput), expectedSig)) return false;
if (now >= out.expiresAt) return false; // 过期即拒
return true;
}

安全红线:① 校验强制 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
2
3
4
5
6
7
// src/service/SessionStore.cc
void SessionStore::save(const std::string &jti, int64_t userId, std::function<void(bool)> &&cb) {
redis_->execCommandAsync([](const drogon::nosql::RedisResult &) { cb(true); },
[](const std::exception &e) { /* SESS_SAVE_FAILED */ cb(false); },
"SETEX %s %d %s", key(jti).c_str(), ttlSeconds_, std::to_string(userId).c_str());
}
// exists: EXISTS sess:{jti} → fail-close;revoke: DEL sess:{jti} → 一行吊销
图 1:登录签发 + 可吊销会话(JWT 凭证 + Redis 权威态) 客户端 UserService Redis MySQL POST /api/user/login SELECT hash,salt,status row ct-compare SETEX sess:{jti} OK {token:eyJ...,type:Bearer} 吊销 = DEL sess:{jti};此后每次鉴权 EXISTS 失败即拒

五、控制器与错误码

登录控制器从 body 取 phone/password,把业务拒绝码映射成 409、系统错误映射成 500(与 3.1 同一套 statusForFailure 规则)。

1
2
3
4
5
6
// src/controllers/UserController.cc(login 节选)
svc_->login(phone, password, [callback](bool ok, const std::string &token, const std::string &msg) {
if (!ok) { callback(jsonResp(statusForFailure(msg), 1, msg)); return; }
Json::Value data; data["token"] = token; data["tokenType"] = "Bearer";
callback(okWithData(std::move(data))); // 200 {code:0,data:{token,tokenType}}
});

登录 / 注册的错误码(节选):

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
2
3
4
5
6
7
8
# 登录 → 拿 token
curl -s -X POST http://127.0.0.1:8080/api/user/login \
-H 'Content-Type: application/json' \
-d '{"phone":"13800001111","password":"abc123"}'
# 返回 {"code":0,"msg":"success","data":{"token":"eyJ...","tokenType":"Bearer"}}

# 一键自检整条链路(发码→注册→登录→撞库锁定→登出→旧 token 失效)
bash scripts/verify-auth.sh 13800001111 abc123

功能抉择(本篇核心权衡)

① 为什么密码比对必须走常量时间?
== / 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 ⭐