写在前面
给登录接口加频控这件事,起因是一次自查:我把登录接口的日志拉出来看,发现某个测试账号在几分钟内产生了上百条 401 用户名或密码错误——是同事写脚本压测时顺手干的。当时我突然意识到,这个接口对任何人都是无限次尝试的:没有次数限制,没有锁定,错了就再试,理论上可以一直试到试对为止。
内网系统尚且如此,放到公网这就是标准的密码爆破入口。这篇记录我给登录接口加"失败锁定"的过程:策略本身不复杂,但有几个坑值得单独说。
一、最直觉的方案为什么不行
第一反应是用内存计数:一个 Map<String, Integer>,登录失败就 +1,到 5 次就拒绝。这个方案撑不过三个问题:
重启即失忆。 服务一重启,计数全部归零。攻击者只要诱导一次重启(或者干脆等一次发版),就能重新来五轮。
多实例不共享。 就算后面部署了两个实例,负载均衡把请求打散,每个实例各记各的账——每实例 5 次,实际就是 10 次。
内存只涨不清。 每个"用户名+失败"都要记一条,攻击者可以换着用户名刷,Map 无限膨胀。你得自己写过期清理,结果等于手搓了一个 Redis。
所以结论很明确:这种带过期时间的全局计数,就是 Redis 的主场。
二、Redis 计数:INCR + TTL
先看完整实现,坑在后面逐个说:
@Service
public class AuthLockService {
private static final String KEY_PREFIX = "auth:login:fail:";
private static final int MAX_FAIL_COUNT = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(30);
private final RedisTemplate<String, Object> redisTemplate;
private final String keyPrefix;
public boolean isLocked(UserType userType, String loginName) {
Integer count = (Integer) redisTemplate.opsForValue().get(buildKey(userType, loginName));
return count != null && count >= MAX_FAIL_COUNT;
}
public int recordFailure(UserType userType, String loginName) {
String key = buildKey(userType, loginName);
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
// 第一次失败时设置 TTL
redisTemplate.expire(key, LOCK_DURATION.getSeconds(), TimeUnit.SECONDS);
}
return count != null ? count.intValue() : 0;
}
public void clearFailures(UserType userType, String loginName) {
redisTemplate.delete(buildKey(userType, loginName));
}
public int getRemainingAttempts(UserType userType, String loginName) {
Integer count = (Integer) redisTemplate.opsForValue().get(buildKey(userType, loginName));
if (count == null) return MAX_FAIL_COUNT;
return Math.max(0, MAX_FAIL_COUNT - count);
}
private String buildKey(UserType userType, String loginName) {
return keyPrefix + KEY_PREFIX + userType.name() + ":" + loginName;
}
}
几个设计点:
Key 里为什么带 userType? 因为系统里有员工和管理员两类账号,登录名可能撞车——“admin01” 既可能是员工工号也可能是管理员账号。计数必须按 类型:账号 隔离,否则锁错人。外层还有一个可配置的 keyPrefix,多套环境共用一个 Redis 时避免互相串台。
为什么"剩余次数"要单独查询返回? 用户体验上,“用户名或密码错误"和"用户名或密码错误,剩余尝试次数 2"是完全不同的感受。前者让人懵,后者让人在锁死前想起来去点"忘记密码”。防爆破不代表要把真实用户当成敌人——限制要狠,提示要暖。
三、接进登录流程
策略是死的,接入点决定了它是否严密。登录主流程里一共三个钩子:
private LoginResponse doLogin(UserType userType, LoginRequest request, ...) {
// 1. 进门前:锁定的直接 429 拒绝
if (authLockService.isLocked(userType, loginName)) {
throw new BizException(429, "登录失败次数过多,账号已锁定 30 分钟");
}
try {
authentication = provider.authenticate(...);
} catch (BadCredentialsException | UsernameNotFoundException e) {
// 2. 密码错:计数 +1,并告知剩余次数
int failCount = authLockService.recordFailure(userType, loginName);
int remaining = authLockService.getRemainingAttempts(userType, loginName);
log.warn("登录失败 userType={} loginName={} failCount={} remaining={}",
userType, maskLoginName(loginName), failCount, remaining);
throw new BizException(401, "用户名或密码错误,剩余尝试次数 " + remaining);
}
// 3. 登录成功:清零失败计数
authLockService.clearFailures(userType, loginName);
...
}
三个钩子各管一件事,有两个细节我特别想强调。
锁定期内不做认证,直接拒绝。 isLocked 检查放在 authenticate 之前——哪怕密码是对的也照样 429。这不是 bug:如果锁定期间"密码对就放行",攻击者多试几组候选密码,每次都能探测"这组对不对",防爆破就漏了底。代价是真实用户在锁定期内输对密码也进不来,所以必须配一条可用的找回密码通道,否则客服会被"我被锁了"的工单淹没。
"用户名不存在"和"密码错误"返回同一个文案。 UsernameNotFoundException 和 BadCredentialsException 走同一个 401 文案,这是防枚举的基本功——让攻击者无法通过错误信息区分"这个账号存不存在"。日志里照常区分记录,方便内部排查,但对外不能漏。
另外注意日志里用了 maskLoginName——登录名会进日志,做脱敏是应该的默认动作。
四、三个容易踩的坑
坑一:INCR 和 EXPIRE 不是原子的
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, ...); // 这一步如果没执行成功?
}
increment 和 expire 是两条命令,中间隔着一次网络往返。如果进程恰好在这两步之间挂了(或者 expire 因为网络抖动失败了),这个 key 就永远没有过期时间——计数只增不减,账号会在第 5 次失败后被永久锁定,直到有人手动去 Redis 删 key。
这是我评审自己代码时最容易放过、但出问题最刁钻的一类。解法有两种:
- 用 Lua 脚本把"自增 + 首次过期"合并成一次原子操作;
- 或者更简单:每次失败都执行
expire,多一次网络往返,换一个"永远有过期时间"的不变量。
个人项目我推荐后者——原子性用幂等来近似,代码简单到不会写错。
坑二:"锁定 30 分钟"其实不一定是 30 分钟
TTL 是第一次失败时设置的,后续失败不会刷新它。推演一下这个时间线:
10:00 第 1 次失败,key 设置 TTL 30 分钟(11:30 过期)
10:04 第 2 次失败
10:09 第 3 次失败
10:13 第 4 次失败
10:16 第 5 次失败 → 计数到 5,锁定
11:30 TTL 到期,key 消失,计数归零 —— 锁定解除
第 5 次失败发生在 10:16,但锁定在 11:30 才解除——实际锁了 74 分钟,比承诺的 30 分钟长了一倍多。反过来,如果五次失败分散在 25 分钟里,最后锁定就只剩 5 分钟,比 30 分钟短。
这是个策略语义问题,不是 bug:我真正想要的语义是"从最近一次失败算起 30 分钟",那 TTL 应该在每次失败时刷新。而"从第一次失败算起"也有它的好处——攻击者不能通过"每 29 分钟输错 4 次再等过期"来无限试探,因为窗口不会被重置。
我的取舍:保持现状(首失败起算),因为对内部系统来说"偶尔锁久一点"比"被持续试探"安全得多。但你至少要知道自己选的是哪种语义,别让客服按"30 分钟"承诺,而系统实际锁了 74 分钟。
坑三:按账号锁定,会被反向利用
按 loginName 计数意味着:任何知道你账号的人,只要故意输错 5 次密码,就能把你锁在门外。这是防爆破方案的固有代价——锁定攻击者,也等于给了攻击者一把锁你的钥匙。
评估这个风险要看系统性质:公网注册系统(如邮箱、社交)被恶意锁号的成本很高,通常要叠加"按 IP 限流 + 图形验证码";而我这个企业内部系统,账号名单公司可见,恶意锁号的收益极低、可追溯性极强(有审计日志),这个代价可以接受。
但有一条底线必须守住:锁定只挡登录,不挡找回密码。 被恶意锁定的真实用户要有一条不需要"登录成功"就能自救的路,否则锁定就变成了拒绝服务的工具。
五、为什么不用数据库
也考虑过用一张 login_fail_count 表来做,好处是看得见摸得着、能做后台管理页。但两个硬伤:计数热点行要靠 UPDATE ... SET count = count + 1 串行排队,爆破场景下这个表的行锁会成为整个登录链路的瓶颈;过期清理要么靠定时任务扫表,要么靠查询时判断时间戳——都是自己手搓 Redis 已经提供的能力。
频率高、带过期、结构简单的数据,放 Redis;需要展示、关联、审计的数据,放数据库。失败计数属于前者,登录成功与否的记录属于后者——所以我的审计日志(谁在几点几分尝试登录、失败几次)仍然落在数据库里,只是"挡人的计数器"在 Redis。
六、小结
三点体会:
- 频控的难点不在策略,在边界。 INCR+TTL 十分钟就能写完,但原子性、锁定语义、和找回密码通道的配合,才是决定它"能不能上生产"的部分。
- 限制要狠,提示要暖。 告诉用户还剩几次机会、被锁了多久、以及一条不依赖登录的自救路径,防爆破才不会误伤真实用户。
- 每个方案都有反向利用的面。 按账号锁会被用来锁别人,按 IP 锁会误伤共用出口的公司网络。选哪把刀,取决于你的系统性质——先想清楚攻击者拿到它会切哪儿。
上线之后我把那个压测脚本又跑了一遍:第 6 次尝试开始,接口稳定返回 429。日志里的 remaining=0 像一道闸门——五次机会,用完即止。
默认评论
Halo系统提供的评论