登录接口(3.2)能比对密码了,但还差最后一道防线的"门闩":密码错误次数限制 + 账号临时锁定。没有它,攻击者可以对一个手机号无限次试密码(撞库);有了它,连续错 N 次就临时锁几分钟,把撞库成本抬高到不划算。

配套代码:src/service/LoginGuard.{h,cc}(checkLocked / onFailure / onSuccess + kOnFailureScript / kCheckScript)、UserService::login 里埋的 guard_-> 调用。

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

是什么:LoginGuard 用 Redis 记录每个手机号的密码失败次数,连续失败达 max_fail(默认 5)就 SETEX login:lock:{phone} 锁定 lock_seconds(默认 600);登录成功清计数;登录前先查锁定。

坑在哪:

  • 计数放进程内存:多实例部署时每台各记各的,"5 次就锁"变成"5×N 次才锁"。
  • 计数竞态:10 个请求同时读到 4、各自 +1 写回 5,实际失败 10 次却只记 5 次。
  • 锁定检查卡死登录:Redis 挂了时把全部登录拒掉,业务损失比撞库更大。

本质一句话:登录加固的本质是"用 Redis 共享计数 + Lua 原子阈值 + 临时锁定"抬高撞库成本——计数必须共享、判定必须原子、查锁失败要 fail-open。

二、LoginGuard 三接口

1
2
3
4
5
6
7
8
9
// src/service/LoginGuard.h
class LoginGuard {
public:
void checkLocked(const std::string &phone, std::function<void(bool, int)> &&cb); // 登录前问锁定
void onFailure(const std::string &phone, std::function<void(const FailResult &)> &&cb); // 密码错+1
void onSuccess(const std::string &phone, std::function<void()> &&cb); // 登录成功清计数
static std::string failKey(const std::string &phone) { return "login:fail:" + phone; }
static std::string lockKey(const std::string &phone) { return "login:lock:" + phone; }
};

FailResult 区分三种结果:Counting(还在累加,附 failCount)、LockedNow(本次触发锁定)、AlreadyLocked(此前已锁)。

三、onFailure 的 Lua:INCR + 判阈 + SETEX 锁

onFailure 的脚本把"查锁 → 计数 +1 → 判阈值 → 锁定"放进单线程原子执行。failKey 的 TTL 也设成 lockSeconds——这是滑动窗口语义:用户第 1 次输错后,只要 lockSeconds 内凑不满 max_fail 次,计数自然清零;设太短攻击者慢慢撞,设太长误输用户会被莫名锁。与锁定窗口取齐最省心。

1
2
3
4
5
6
7
8
9
10
11
12
-- kOnFailureScript(KEYS: lockKey/failKey;ARGV: maxFail/lockSeconds)
if redis.call('EXISTS', KEYS[1]) == 1 then
return {-1, redis.call('TTL', KEYS[1])} -- 已锁定,返回剩余秒
end
local n = redis.call('INCR', KEYS[2])
if n == 1 then redis.call('EXPIRE', KEYS[2], ARGV[2]) end
if n >= tonumber(ARGV[1]) then
redis.call('SETEX', KEYS[1], ARGV[2], '1') -- 触发锁定
redis.call('DEL', KEYS[2])
return {-2, tonumber(ARGV[2])}
end
return {n, 0} -- 还在计数
1
2
3
4
5
6
7
// src/service/UserService.cc(密码错分支,3.2 已埋)
guard_->onFailure(phone, [cb](const FailResult &fr) {
if (fr.kind == FailResult::Kind::Counting)
cb(false, "", "WRONG_PASSWORD:" + std::to_string(fr.failCount)); // 明确告知还能试几次
else
cb(false, "", "ACCOUNT_LOCKED:" + std::to_string(fr.lockSeconds));
});

四、fail-open vs fail-close:两条红线不能混

LoginGuard 在 Redis 异常时的取舍与 SessionStore 故意相反:

  • 锁定检查 checkLocked:fail-open(放行)。锁定是"可用性"维度的保护,Redis 挂掉时把所有登录拒掉,业务损失比撞库更大;且撞库本身还有 failKey 计数在兜。
  • 会话校验 SessionStore::exists:fail-close(拒绝)。鉴权是"安全性"维度,宁可误杀已登录用户让其重登,也不能在 Redis 抖动时放行进被封禁的会话。

一句话:鉴权要收紧(宁可误杀),限流要放松(宁可放过),两者不能混为一谈——这是安全设计里最容易踩的反直觉点。

五、与登录主链路集成

UserService::login 在 3.2 里已经埋好三处调用:① 进门先 checkLocked(被锁直接拒,连密码都不查);② 密码错 onFailure(记次数 / 触发锁定);③ 成功 onSuccess(清计数)。本篇只是把这套 Lua 原子逻辑讲透。

六、对比表

维度 进程内存计数 Redis + Lua(本文)
多实例语义 失效(5×N 次才锁) 共享单一态,不变
计数竞态 有(并发少计) 无(Lua 原子)
Redis 挂了 — 锁定检查 fail-open,不阻断登录
计数清零 需额外定时 滑动窗口 TTL 自动清零

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

① 为什么计数必须放 Redis 而不是进程内存?
进程内存只对单实例有效。攻击者把撞库请求打散到多实例,每台各记各的,"5 次就锁"变成"5×N 次才锁"。Redis 是共享单一计数源,多实例部署语义不变——这也是秒杀要上多实例的前提。

② 为什么"INCR + 判阈 + SETEX"必须一个 Lua?
三条命令分开做有两类竞态:计数竞态(10 请求同时读 4、各 +1 写回 5,实际失败 10 次只记 5);锁定竞态(两请求同时跨阈值各自 SETEX)。Lua 在 Redis 单线程把"读-改-写"变不可分割,从根消除。

③ 为什么锁定检查要 fail-open、会话校验要 fail-close?
两者保护维度不同:锁定是可用性(放过比拒掉损失小),鉴权是安全性(拒掉比放过损失小)。混用任一方都会出事——锁检查 fail-close 会在 Redis 抖动时全员登不进;会话校验 fail-open 会在 Redis 抖动时放进被封禁会话。

小结

  • 登录加固 = Redis 共享失败计数 + Lua 原子阈值 + 临时锁定(max_fail=5 / lock_seconds=600)。
  • 计数用滑动窗口 TTL(与锁定窗口取齐),lockSeconds 内凑不满阈值自动清零。
  • fail-open vs fail-close 故意相反:锁定检查放松(Redis 挂不阻断登录),会话校验收紧(Redis 挂也拒进封禁会话)。
  • 三处调用已埋进 UserService::login:进门查锁、密码错记次、成功清计数。🐾

配套源码

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

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

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

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