做后端这些年,登录接口的安全问题几乎天天有人在群里问。其中最高频的需求之一就是:同一个用户1分钟内登录失败超过3次要锁定。听起来是一句话的活儿,真正落地的时候坑却不少——要不要把IP也算上?锁多久?过期时间设置多少合理?用户被误锁了怎么解除?分布式多台机器怎么保证计数不歪?今天不聊虚的,直接把这套逻辑从设计到代码再到排查,完整拆开讲清楚。适合正在做账号系统后端的同学,尤其是那些系统上线后天天被爆破请求骚扰的项目,可以照着这篇文章自己撸一套防爆破机制。
1. 先搞清楚需求本质:这句“1分钟3次”在防什么
1.1 暴力破解的攻击路径
登录接口本质上是一个“验证密码对不对”的入口。如果接口没有任何限制,攻击者就可以拿用户名列表,逐个用户、逐个常用密码去试,速度能达到每秒几十次甚至上百次,几分钟就能把一套常见弱密码库跑完。像“123456”“password”“qwerty123”这类弱密码,碰撞成功率相当可观。这就是典型的暴力破解,也是“1分钟3次锁定”这类需求要解决的核心问题。
所以这个需求的核心目的,是让暴力破解的效率急剧下降。正常用户输错一两次密码是正常的,但如果一分钟内尝试4次、5次、10次,基本可以判定是自动化脚本在跑。把这个通道切断,攻击者每一次尝试的成本就从“一次HTTP请求”变成“等待解锁时间”,破解效率立刻降到不划算的水平。这个思路在任何带账号体系的产品里都适用,不管是后台管理系统、电商前台,还是App开放平台。
1.2 需求拆解:四个关键决策点
这句话看着明白,但每个词背后都藏着选择。我通常会把需求拆成四个问题,这四个问题不敲定,代码写起来容易返工。
- 锁的对象是谁?账号维度最简单,IP维度能防同一个IP扫多个账号,设备指纹维度最精准但实现成本高。一般建议先做账号维度,再叠加IP维度。
- 锁多长时间?有的系统锁5分钟,有的锁15分钟,有的干脆锁到第二天,取决于业务风险等级。金融类系统会要求更严格,普通社区站点临时锁15分钟相对合理。
- 计算什么行为?是只算密码错误,还是验证码错误也算?我的建议是两种都算,因为自动化脚本很少去正确识别验证码,一旦被验证码挡住,它们的请求同样会打到登录接口。
- 锁定后的响应是什么?是直接拒绝登录,还是要求输入验证码,或者发送一条短信验证码进行解封?不同方案对用户体验影响很大。
这四个问题定了,技术实现才有明确依据。很多人一上来就写代码,结果产品临时说“锁10分钟吧”,虽然改起来不难,但Key的设计、过期策略最好从一开始就要能灵活调整,这是所有方案里最容易忽略的一点。
2. 三种常见实现方案:内存、数据库、Redis怎么选
2.1 单机内存方案:简单但适用范围有限
在早期单体应用里,直接在进程里维护一个Map或本地缓存记录登录失败次数,是很多人第一反应的做法。比如用并发安全的HashMap,key是用户名,value是失败次数和第一次失败时间,写一个定时清理或者随请求清理过期记录。
这个方案的优点是真的简单,几行代码就能跑起来。但缺点同样明显:应用一重启内存就清零;多台服务器负载均衡时,A机器记录了3次失败,用户下一次请求被转发到B机器,B机器不知道前面已经失败了3次,锁定直接失效。所以这个方案只适合个人项目、单机部署或安全要求很低的内部系统。但凡系统要上集群,或者有对外价值,这套代码就得推翻重来。
2.2 数据库方案:适合低并发的简单系统
把失败次数落库,建一张登录失败记录表,字段包含用户名、IP、失败时间、是否已锁定等。每次登录失败插一条记录,查询时统计最近60秒内记录数量,超过阈值就写入一条锁定记录。
这个方案的好处是数据可以持久化,多实例访问同一份数据库,天然一致。坏处也很明显:登录接口是极高频率的接口,如果每次失败都插库,而且查锁定时需要聚合统计,数据库压力会非常大。尤其是遭遇攻击时,攻击本身就变成了对数据库的写入放大攻击,数据库很容易被打慢。数据库方案可以接受,但我更推荐做成“失败记录落库 + Redis计数前置”的混合方案,数据库只用来审计,真正的拦截判断走缓存。
2.3 Redis方案:分布式环境下的主流选择
Redis做这类计数几乎是标配,原因有这么几点:
- INCR、EXPIRE这些命令天然适合做计数器。
- 所有服务实例共享同一个Redis,不存在多机数据不一致的问题。
- 每次登录就一两次Redis交互,性能很好。
- 数据可以设置过期时间,不用自己写清理任务。
所以除非你们的系统连Redis都没上(那这篇文章的方案可以先存着备用),否则我强烈建议直接上Redis。接下来的完整落地也以此为主线。
3. Redis方案的核心设计与完整落地
3.1 Key设计:决定你后续能不能快速排查问题
设计Key要注意可读性、可分区性和可过期性。我常用的Key格式是:
login:fail:user:{username}:记录某个用户连续失败次数login:fail:ip:{ip}:记录某个IP连续失败次数login:lock:user:{username}:记录用户锁定状态login:lock:ip:{ip}:记录IP锁定状态
为什么要把失败次数和锁定状态分开?因为次数只是一个中间态,锁定才是最终判决。分开设置,可以让计数的过期时间和锁定的过期时间互相独立。比如计数窗口设置60秒,锁定时间设置900秒(15分钟),互不干扰。
用户名和IP如果包含特殊字符,要注意统一编码,避免生僻Key。更重要的是,在代码里固定规则:用户名统一转小写再拼接?IP地址统一去掉端口?这些细节很多人忽略,等到线上数据不统一,想排查就麻烦了。另外,每个Key都要设置合理的过期时间,不要留永久Key,否则Redis里会堆积大量无用的登录记录。
3.2 原子计数:别用“先读再写”那套
最容易写错的地方,是有人在代码里先GET次数,然后再INCR。这种做法在并发下百分百出问题,两个请求同时GET到2,又同时INCR,最后计数只变成3,该锁的没锁上。
正确做法是用Redis的INCR命令,它是原子自增。但INCR和EXPIRE两个命令分开执行也存在一个窗口问题:第一次INCR成功之后,如果进程挂了或者Redis响应超时,EXPIRE没执行成功,这个Key就变成一个永不过期的计数器,用户失败次数会一直累计,之后哪怕过了一个小时,用户一登录就直接被判为“该锁定了”。
解决这个问题最干净的办法,是把计数、设置过期时间、判断阈值、设置锁定,全部塞进一个Lua脚本里。Redis对Lua脚本是单线程执行的,天然具备原子性。
脚本大概长这样:
-- KEYS[1]: 失败计数Key -- KEYS[2]: 锁定Key -- ARGV[1]: 时间窗口,单位秒 -- ARGV[2]: 锁定时间,单位秒 -- ARGV[3]: 锁定阈值,失败次数达到该值即锁定 local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end if count >= tonumber(ARGV[3]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[2]) redis.call('DEL', KEYS[1]) return 1 end return 0这里有个语义细节要说明:需求里写“超过3次则锁定”,有的人理解为第4次失败才算“超过”,有的人则直接把“3次”当成锁定阈值。一般来说,产品同学口头说的“3次”,落库里往往就是“失败次数达到3就锁”。为了避免歧义,在开工前和产品对齐一下口径。上面脚本里ARGV[3]传3,就是失败3次即锁;如果要严格“超过3次”,把阈值传4就行。
有人会问,锁定之后为什么要DEL删除计数Key?因为锁定状态已经用单独的Lock Key表达了,计数留着只会让解锁逻辑混乱。把计数清零,锁定到期后用户重新尝试,计数从0开始,语义更干净。
3.3 登录流程主链路改造
在真正校验密码之前,先查询锁定状态。不管是用户维度还是IP维度,只要锁了,直接拒绝放行,返回业务错误码。示例逻辑大概是这样:
public LoginResult login(String username, String password, String ip) { // 第一步:查锁定 if (isUserLocked(username)) { return Result.error("账号已锁定,请15分钟后再试"); } if (isIpLocked(ip)) { return Result.error("当前网络环境已被临时限制"); } // 第二步:校验账号密码 User user = userService.queryByUsername(username); if (user == null || !passwordEncoder.matches(password, user.getPassword())) { // 失败计数 recordFailure(username, ip); return Result.error("账号或密码错误"); } // 第三步:登录成功,清除失败记录 clearFailureRecord(username, ip); return Result.success(user); }查锁这一步用Redis的EXISTS就行,开销很低。失败计数调用前面那段Lua脚本,入参就是两个Key和三个参数。在Java里配合Spring Data Redis执行脚本大概这样:
private Long executeLua(List<String> keys, String window, String lockTime, String threshold) { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(LUA_SCRIPT); script.setResultType(Long.class); return redisTemplate.execute(script, keys, window, lockTime, threshold); }这里有个非常常见的坑:查询锁定状态是放在密码校验前,但很多人忘了在密码错误时也先判断一下当前是否已经处于锁定状态。也就是说,如果用户已经锁了,后续请求还在继续尝试,应该直接返回锁定提示,而不是继续走失败计数流程。否则计数Key会被反复重置过期时间,语义就乱了。
3.4 时间窗口口径:固定窗口和滑动窗口的区别
“1分钟内3次”实现上其实有两个口径。
固定窗口:每过1分钟,计数清零。用上面的Lua脚本,INCR第一次时设置EXPIRE 60秒,只要60秒一到Key就自动删除。实现最简单,但存在临界问题:用户在59.5秒失败了3次,又在60.5秒失败了3次,虽然横跨两个窗口,但实际间隔只有2秒,攻击者可以对窗口边界做偏移,绕开连续密集尝试的限制。
滑动窗口:真正统计“任意连续60秒内”的失败次数。Redis没有自带滑动窗口结构,常见有两种实现方式:
- 用ZSET记录每次失败的时间戳,查询时删除60秒前的记录,再统计集合内数量;
- 把60秒拆成多个固定小窗口,比如6个10秒计数器,查询时聚合求和。
对于登录防爆破这个场景,说实话固定窗口往往够用,攻击脚本并不会刻意卡在窗口边界上。但如果你要给客户交付一个“严谨的风控方案”,建议直接上滑动窗口。用ZSET实现也不复杂:
ZADD login:fail:window:user:{username} <当前毫秒时间戳> <唯一串> ZREMRANGEBYSCORE login:fail:window:user:{username} 0 <当前毫秒时间戳-60000> ZCARD login:fail:window:user:{username}每次失败插入一个带时间戳的成员,先清理60秒以前的旧记录,再数一数剩余数量,超过阈值就锁。ZSET成员需要一个唯一串,可以用时间戳加随机数,避免同一毫秒内多个请求覆盖同一成员。这个方案的时间复杂度取决于集合长度,在登录失败量级下完全没有问题。
3.5 参数配置:给一组可直接上线的默认值
参数设置没法一步到位,但得有合理起点。我做过的项目里用过一组通用配置,给大家参考:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 时间窗口 | 60秒 | 对应需求里的“1分钟” |
| 锁定阈值 | 3次 | 连续失败3次触发锁定 |
| 锁定时长 | 900秒 | 15分钟,防空转又不过度惩罚 |
| IP维度阈值 | 20次/小时 | 防止同一出口IP下多个账号被同时误伤 |
| 白名单用户 | 不限制 | 管理员、客服账号等可配置豁免 |
阈值这块有个亲身感受:如果系统经常出现用户误锁,不一定是因为阈值定低了,而是因为没有做失败次数的前端提示升级。比如第一次到第二次失败时提示“还剩最后一次机会”,第3次失败后前端立刻引导走“找回密码/验证码解锁”。用户体验是从这些细节里省出来的,不是靠把阈值放宽到10次来省。
4. 上线后最常见的坑:问题与排查实录
4.1 并发下计数不准,结果是该锁的没锁
真实项目里遇到过这个现象:压测时模拟同一个用户同时发5个登录请求,理论上第3个失败后就应该锁定,但实际查Redis,计数只到2或者4,完全不按剧本走。
排查后发现,原因是代码里用的不是原子操作。业务方在代码里先查询当前失败次数,再决定是否自增,自增用单独一次INCR,查询用GET,中间没有任何锁保护。多个请求同时进入时,GET返回的都是同一个旧值,看起来“当前只有2次”,于是每个请求都放行并自增,最终计数和判断逻辑全部乱套。修复方式就是前面提到的Lua脚本,一步到位,以后不要再有“先GET再判断再INCR”的写法。
4.2 锁IP还是锁账号:误伤怎么处理
IP维度是一把双刃剑。办公室、学校、运营商出口共享IP的环境下,一个人输错3次密码,同网段的其他人全部跟着锁,这是最常见的误伤案例。我处理过的一个现场:办公网出口IP被某个爆破脚本扫了几下,当天全公司员工都登录不了系统,排查半天才定位到IP维度锁得太狠。
我的建议是:账号锁定要严格,IP锁定可以放宽。比如账号维度3次/60秒锁15分钟,IP维度做成“同一个IP对多个不同账号失败合计超过一定次数才触发限制”,这样既挡住了横扫全站用户名列表的脚本,又不至于因为一个手滑的用户连累所有人。还有一种方案是IP维度只封常见云主机机房IP段,普通住宅/办公IP不做强限制,这对国内企业用户的体验友好很多。
4.3 锁定后解不了锁:解锁流程缺失
“只锁不解”是新手团队里出现概率极高的问题。需求提的是“锁定用户”,没人提“怎么解锁”,于是用户被锁之后只能干等15分钟。可有的人等不了,管理员账号自己锁了,电话打到运维那边,运维也不知道怎么解。
比较好的做法是:在后台管理端提供“解除锁定”的入口,有权限的管理员可以查用户锁定状态并一键解锁。同时给被锁用户一个自助解锁通道,比如发送短信验证码,验证通过即可立即解除锁定。这个通道本质上是做了一次更严格的身份核验,但比单纯等待好得多。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查思路 | 解决建议 |
|---|---|---|---|
| 用户频繁被锁 | 阈值过低或没做提示 | 查看Redis里计数Key的剩余TTL | 调整阈值,增加前端剩余次数提示 |
| 锁定了还能继续登录 | 查询锁时Key没查,或锁的Key类型不对 | 看登录日志是否走到了校验密码那一步 | 登录校验前统一查锁状态 |
| 锁了1分钟后自动解锁 | 锁定Key的EXPIRE参数配了60秒 | 检查锁定Key的TTL | 锁定Key与计数Key用不同过期时间 |
| 多台机器行为不一致 | 用了内存计数方案 | 看架构图确认是否共享Redis | 切换到Redis方案 |
| Redis被刷爆 | 攻击打满计数Key写入 | 看监控大Key和写入量 | 给Redis加防护,限制失败计数请求本身 |
5. 从锁定到风控:再往前走半步
5.1 分级响应机制
“1分钟3次锁定”本质是一个二值策略:要么不锁,要么锁。更完善的做法是分级响应:第一次到第二次失败只返回提示;第三次失败要求输入验证码;第四次失败锁定15分钟;如果同一个账号一周内被锁定多次,升级为需要短信验证才能登录。分级响应能让正常用户觉得系统并不粗暴,也让攻击者的尝试空间被进一步压缩。
实现上就是在锁定之前多几个判断节点,成本不高,但对整体体验的影响是量级的。尤其是做C端产品,不能因为一个防爆破需求把用户活活气走。
5.2 监控与告警
锁了用户之后,不能只闷头拦截。我建议至少记录三类日志:锁定事件、解锁事件、IP维度命中事件。每天跑一个简单统计,看看哪些用户/IP触发了大量失败,这往往是撞库攻击的前兆。
告警层面,当某个IP的失败次数在单位时间内猛增,或者某个用户名被尝试的失败次数异常高时,要能通知到人。你永远不会后悔提前发现攻击者。
5.3 别把安全都压在登录接口上
最后说一句经验之谈:登录接口限流是必须的,但真正的安全不能只靠登录锁定。密码泄露、撞库成功之后,攻击者直接就进去了,你再怎么锁登录接口也拦不住。所以条件允许的话,上多因素认证,敏感操作再走一次二次校验。这个需求做完,只能算把第一道门装好,后面还有很多门要装。
我自己做这类需求最大的体会是:技术实现真的不难,难的是想清楚要保护谁、惩罚谁、怎么不误伤正常用户。先从一个简单的Redis计数器开始跑,跑通后再叠IP维度、滑动窗口、分级响应,这套东西会越来越稳。等你把“1分钟3次锁定”这个需求吃透了,下一回再遇到“同一IP每小时超50次登录”“设备指纹关联异常”这类风控需求,思路完全是一样的,直接套用这套框架就行。