3.2 把"登录签发 + 写 Redis 会话"讲完,3.4 把"退出即吊销"预览了一句。这一篇把退出登录做成完整接口:POST /api/user/logout,从 Authorization: Bearer 取 Token、验签、删 sess:{jti}——删掉那一刻,旧 Token 立即失效。

配套代码:src/controllers/UserController.cc(logout + extractBearerToken)、src/service/UserService.cc(logout / authenticate)、src/service/SessionStore.cc(revoke)。

一、是什么、坑在哪、本质一句话

是什么:退出登录 = 客户端带上 Token,服务端验签通过后删除 Redis 里的 sess:{jti}。JWT 本身无法被"改",但此后每次鉴权都会因 EXISTS sess:{jti} 失败而被拒。

坑在哪:

  • Token 不带 Bearer 前缀:直接拿 Authorization 当 token 用,验签必失败。
  • 吊销后仍返回"成功":DEL 一个已不存在的 key 返回 0,若不分清会让客户端误以为"退掉了"。
  • 只删不验签:不验签就删,攻击者拿任意字符串就能把别人的会话删掉(虽然后端要先知道 jti,但严谨起见验签前置)。

本质一句话:退出的本质是"让凭证失效"——验签前置 + 删 Redis 权威态,一步即吊销,且要区分"退掉了"和"本来就没登录"。

二、控制器:Bearer 提取 + 401 处理

UserController::logout 先从 Authorization 头抽 Bearer 前缀后的 token;抽不到(没带 / 格式错)直接 401。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/controllers/UserController.cc(节选)
void UserController::logout(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback) {
const std::string token = extractBearerToken(req); // 去 "Bearer " 前缀
if (token.empty()) { callback(jsonResp(401, 401, "missing bearer token")); return; }
svc_->logout(token, [callback](bool ok, const std::string &msg) {
if (!ok) { callback(jsonResp(401, 401, msg)); return; } // 验签失败 401
callback(okWithData(Json::Value())); // 200 {code:0}
});
}

// 从 Authorization: Bearer <token> 取 token;空串=未认证
std::string extractBearerToken(const drogon::HttpRequestPtr &req) {
const std::string &auth = req->getHeader("Authorization");
if (auth.size() <= sizeof(kBearerPrefix) - 1) return {};
if (auth.compare(0, sizeof(kBearerPrefix) - 1, kBearerPrefix) != 0) return {};
std::string token = auth.substr(sizeof(kBearerPrefix) - 1);
while (!token.empty() && (token.back() == ' ' || token.back() == '\r')) token.pop_back();
return token;
}

三、服务层:verify → revoke(DEL 即吊销)

UserService::logout 先 jwt_->verify 验签(失败 → TOKEN_INVALID,401);通过则 sessions_->revoke(jti)。revoke 的 DEL 返回被删 key 数:0 表示会话本来就不存(已过期/已退出),据此区分"退掉了"和"本来就没登录"。

1
2
3
4
5
6
7
8
// src/service/UserService.cc(logout 节选)
void UserService::logout(const std::string &token, std::function<void(bool, const std::string &)> &&cb) {
JwtClaims claims;
if (!jwt_->verify(token, claims)) { cb(false, "TOKEN_INVALID"); return; } // 验签前置
sessions_->revoke(claims.jti, [cb](bool deleted) {
cb(true, deleted ? "OK" : "ALREADY_LOGGED_OUT"); // DEL 返回 0 = 本就没登录
});
}
1
2
3
4
5
6
7
// src/service/SessionStore.cc(revoke)
void SessionStore::revoke(const std::string &jti, std::function<void(bool)> &&cb) {
redis_->execCommandAsync(
[cb](const drogon::nosql::RedisResult &r) { cb(r.asInteger() > 0); }, // DEL 返回删除数
[cb, jti](const std::exception &e) { /* SESS_REVOKE_FAILED */ cb(false); },
"DEL %s", key(jti).c_str());
}

四、authenticate 中间件(已就绪,等前端接)

UserService::authenticate 是给"网关/中间件"用的:验签 + 查 sess:{jti} 是否存在,返回 userId(失败为 0)。它和 Jwt / LoginGuard 一起已经写好,就等"开始有前端"阶段把 /api/seckill 从 body 双通道切到 Authorization: Bearer 取 userId。

1
2
3
4
5
6
7
8
// src/service/UserService.cc
void UserService::authenticate(const std::string &token, std::function<void(int64_t)> &&cb) {
JwtClaims claims;
if (!jwt_->verify(token, claims)) { cb(0); return; } // 验签失败
sessions_->exists(claims.jti, [cb, userId = claims.userId](bool alive) {
cb(alive ? userId : 0); // 会话不在=已吊销
});
}

当前阶段一的取舍:/api/seckill 现在 userId 走 JSON body、不校验 token(登录模块 3.1~3.7 已就绪但未接入主链路)。这是为压测便利的有意取舍——保留 body 双通道,登录鉴权接入留待"开始有前端"阶段统一做。详见 README「项目边界:前端页面」。

五、可运行验证(端到端含登出)

1
2
3
4
5
6
7
8
# 一键自检:发码→注册→登录→撞库锁定→登出→旧 token 失效
bash scripts/verify-auth.sh 13800001111 abc123

# 手动验证登出后旧 token 失效:
TOKEN=$(curl -s -X POST localhost:8080/api/user/login -H 'Content-Type: application/json' \
-d '{"phone":"13800001111","password":"abc123"}' | python -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
curl -s -X POST localhost:8080/api/user/logout -H "Authorization: Bearer $TOKEN" # {"code":0}
# 再拿同一个 $TOKEN 去 /api/seckill 或 authenticate → 已被吊销拒绝

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

① 为什么退出的 revoke 要"验签前置 + DEL",而不是只 DEL?
只 DEL 不验签,意味着任意字符串都能触发删会话(虽然后端需先构造合法 jti,但严谨上不应给这扇窗)。验签前置保证"只有持有合法 Token 的人才能吊销自己的会话",且 TOKEN_INVALID 与 ALREADY_LOGGED_OUT 语义分明。

② 为什么 DEL 返回 0 时不算错误,仍回 OK?
DEL 一个不存在的 key 返回 0,这是"会话早已过期/早已退出"的正常状态,不是故障。区分 OK(真删了)与 ALREADY_LOGGED_OUT(本就没登录)只是给客户端更准的反馈,不影响"退出成功"的语义——用户的目的(让凭证失效)已经达成。

③ 为什么 /api/seckill 现在不接 token,留到前端阶段?
阶段一验收硬指标是"压测 QPS 提升一个量级 + 不超卖不重复下单",鉴权对这条指标零贡献;而且 body 双通道让压测脚本不必先走登录拿 token,跑基线更顺。等前端阶段统一把 userId 从 Bearer 取,避免现在改一次、那时又改一次。

小结

  • 退出登录 = extractBearer → 验签 → revoke(DEL sess:{jti}),删掉即吊销,旧 Token 立即失效。
  • Bearer 抽不到 → 401 missing bearer token;验签失败 → 401 TOKEN_INVALID。
  • revoke 用 DEL 返回值区分"退掉了"与"本来就没登录",但都算退出成功。
  • authenticate(验签 + EXISTS)已就绪,等前端阶段把 /api/seckill 切到 Bearer 取 userId。
  • 当前 /api/seckill 保留 body 双通道、不校验 token,是阶段一压测便利的有意取舍。🐾

配套源码

本文对应代码已开源:Hespethorn/seckill-cpp

C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:

  • 阶段一(直打数据库):QPS≈440,0 超卖
  • 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS

如果对你有帮助,欢迎 star ⭐