密码连错五次锁半小时:用 Redis 给登录接口加防爆破

SailTrack
2026-09-07
点 赞
0
热 度
5
评 论
0
  1. 首页
  2. 后端开发
  3. 密码连错五次锁半小时:用 Redis 给登录接口加防爆破

写在前面

给登录接口加频控这件事,起因是一次自查:我把登录接口的日志拉出来看,发现某个测试账号在几分钟内产生了上百条 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。

六、小结

三点体会:

  1. 频控的难点不在策略,在边界。 INCR+TTL 十分钟就能写完,但原子性、锁定语义、和找回密码通道的配合,才是决定它"能不能上生产"的部分。
  2. 限制要狠,提示要暖。 告诉用户还剩几次机会、被锁了多久、以及一条不依赖登录的自救路径,防爆破才不会误伤真实用户。
  3. 每个方案都有反向利用的面。 按账号锁会被用来锁别人,按 IP 锁会误伤共用出口的公司网络。选哪把刀,取决于你的系统性质——先想清楚攻击者拿到它会切哪儿。

上线之后我把那个压测脚本又跑了一遍:第 6 次尝试开始,接口稳定返回 429。日志里的 remaining=0 像一道闸门——五次机会,用完即止。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数