三次输错密码后,系统是怎么做到不让我继续尝试的?
登录失败三次后被“请稍后再试”了?你以为这是系统在“为你好”?其实背后藏着一整套“防暴力破解”机制。
从用户体验来看,这是一种常见的安全交互设计。但从技术角度来看,它涉及到了登录行为监控、数据持久化、状态限制、性能与安全的平衡,甚至还可能与缓存、数据库、分布式锁、验证码联动处理。
这篇文章我们就深度拆解下:系统是怎么做到三次输错密码后,就“不让你再试”的。我们会从三个角度提供实际可落地的技术方案,并结合代码、场景、优缺点进行全方位分析。
方案一:基于缓存计数器 + 过期控制的方案(推荐优先)
应用场景:
- 适用于单体应用或小型分布式应用
- 用户量不算超级大,系统可接受短暂状态缓存
- 想通过简单方案快速限制重复密码尝试
核心思路:
- 每次登录失败,就在缓存(如 Redis)中记录一次失败次数
- 设置一个过期时间窗口(如10分钟),超过时间自动清除
- 如果失败次数 ≥ 阈值(如3次),则禁止登录(抛出异常或返回提示)
实现原理图:
用户名/手机号 + IP 作为 Redis Key
↓
login:fail:username:ip → 失败次数(value)
↓
超过3次?→ 是 → 拒绝登录 & 返回提示
↓
否 → 正常验证密码逻辑
实现代码示例(基于Spring Boot + Redis):
@RestController
@RequestMapping("/auth")
public class LoginController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final int MAX_RETRY = 3;
private static final long BLOCK_MINUTES = 10;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestParam String username, @RequestParam String password,
HttpServletRequest request) {
String ip = request.getRemoteAddr(); // 获取客户端IP
String redisKey = String.format("login:fail:%s:%s", username, ip);
// 获取失败次数
String failCountStr = redisTemplate.opsForValue().get(redisKey);
int failCount = StringUtils.hasText(failCountStr) ? Integer.parseInt(failCountStr) : 0;
if (failCount >= MAX_RETRY) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body("账号已被临时锁定,请10分钟后再试");
}
boolean success = checkPassword(username, password);
if (!success) {
// 增加失败次数
redisTemplate.opsForValue().increment(redisKey);
redisTemplate.expire(redisKey, Duration.ofMinutes(BLOCK_MINUTES));
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("密码错误");
}
// 登录成功,清除失败记录
redisTemplate.delete(redisKey);
return ResponseEntity.ok("登录成功!");
}
private boolean checkPassword(String username, String password) {
// 假设用户存在 & 密码为123456
return "123456".equals(password);
}
}
补充说明:
- key设计:推荐加上IP(或设备指纹),防止不同用户相互影响
- 过期机制:Redis的
expire
用来自动清除key,减轻维护成本 - 清零机制:登录成功后立即
delete
掉key,避免误伤 - 防止穿透:建议使用Lua脚本 + 限流工具(如Sentinel)进一步增强并发控制
存在的问题:
问题 | 说明 |
---|---|
非分布式容错 | 如果你用的是单Redis节点,Redis挂掉后记录就丢了 |
无法精准记录异常场景 | 比如数据库连接失败,也会被算作失败次数 |
依赖缓存准确性 | 若Redis异常或Key被误删,可能影响逻辑正确性 |
优势总结:
- 实现简单、易于维护,代码可读性强
- 基于缓存,不会影响数据库性能
- 适合大多数中小项目的安全需求
- 可配合验证码策略进一步增强验证逻辑
如果你希望在业务初期就上一个稳健的防止密码暴力破解方案,这个缓存+次数计数方式是最实用的第一选择。
方案二:基于数据库持久化记录 + 锁定字段机制(强一致性保障)
适用场景:
- 需要安全等级更高的系统,如企业后台、金融、电商等
- 不能容忍Redis丢失状态,或登录状态需长期记录
- 需要审计失败行为、记录登录历史
核心思路:
- 在用户表或独立登录表中持久化记录登录失败次数、最后失败时间
- 达到最大失败次数时,设置锁定标志 + 锁定时间
- 每次登录时先查询用户状态字段,判断是否锁定、是否可解锁
表结构设计(示意):
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(50) UNIQUE,
password VARCHAR(255),
fail_count INT DEFAULT 0,
last_fail_time DATETIME,
locked_until DATETIME
);
实现代码示例(Spring Boot + JPA):
@RestController
@RequestMapping("/secure-auth")
public class SecureLoginController {
@Autowired
private UserRepository userRepository;
private static final int MAX_RETRY = 3;
private static final long LOCK_MINUTES = 15;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestParam String username, @RequestParam String password) {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户不存在");
}
User user = userOpt.get();
// 判断是否锁定
if (user.getLockedUntil() != null && user.getLockedUntil().isAfter(LocalDateTime.now())) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body("账号已被锁定,解锁时间:" + user.getLockedUntil());
}
if (!passwordMatches(user.getPassword(), password)) {
// 增加失败次数
user.setFailCount(user.getFailCount() + 1);
user.setLastFailTime(LocalDateTime.now());
// 如果达到阈值,锁定
if (user.getFailCount() >= MAX_RETRY) {
user.setLockedUntil(LocalDateTime.now().plusMinutes(LOCK_MINUTES));
}
userRepository.save(user);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("密码错误");
}
// 登录成功,重置状态
user.setFailCount(0);
user.setLockedUntil(null);
user.setLastFailTime(null);
userRepository.save(user);
return ResponseEntity.ok("登录成功!");
}
private boolean passwordMatches(String encodedPassword, String inputPassword) {
// 可接入 BCryptPasswordEncoder 等加密方案
return encodedPassword.equals(inputPassword);
}
}
关键细节说明:
项目 | 说明 |
---|---|
fail_count |
累计失败次数,达到3次则触发锁定 |
last_fail_time |
可用于展示或审计(谁恶意搞我号?) |
locked_until |
解锁时间,到点自动解除封禁,无需人工操作 |
优点分析:
- 强一致性:所有登录状态信息都存在数据库中,避免缓存不一致问题
- 可审计:便于分析黑客行为、展示用户“登录失败历史”
- 易集成:可以和账号状态(如冻结、禁用)统一在一张表里处理
存在的问题:
问题 | 说明 |
---|---|
存在写入压力 | 每次失败都写库,用户量大时要注意并发性能瓶颈 |
实时性稍慢 | 对比Redis方案略慢,读写都走数据库 |
集群间同步需依赖数据库 | 各节点都查同一库,压力需分担 |
可升级建议:
- 配合异步队列 + 延迟任务,做锁定到期解封操作
- 锁定记录拆分出专表,避免污染主用户表(如user_login_status)
- 结合Spring Security提供的
UserDetails#isAccountNonLocked()
增强处理
如果你系统对安全性和数据一致性有很高要求,并且希望不依赖缓存状态、对登录行为可持续记录,这种数据库级方案无疑是“长治久安”的选项。
继续压轴的第三种方案,这一招——有点狠,是那种你登录多试两次,就像踢了马蜂窝一样,系统立马切换到联防模式:
方案三:基于限流+验证码联防机制(风控级防御)
适用场景:
- 用户规模超大,登录请求量高,存在撞库、扫号风险
- 对系统稳定性和安全要求极高:如银行、电商、政务平台
- 要做防刷、防爆破、防批量攻击
核心机制:防御系统不是只靠一个点,而是组合拳
- IP+账号限流(滑动窗口或令牌桶)
- 验证码强制切入(如图形/滑动/短信)
- 账号进入灰名单,行为风控接管
实现方式一:Spring Boot + Bucket4j限流器
@Bean
public Map<String, Bucket> cache() {
return new ConcurrentHashMap<>();
}
private Bucket resolveBucket(String key) {
return cache.computeIfAbsent(key, k -> {
Refill refill = Refill.greedy(5, Duration.ofMinutes(10)); // 10分钟最多5次
Bandwidth limit = Bandwidth.classic(5, refill);
return Bucket.builder().addLimit(limit).build();
});
}
控制器中限流判断:
@PostMapping("/login")
public ResponseEntity<?> login(@RequestParam String username,
@RequestParam String password,
HttpServletRequest request) {
String ip = request.getRemoteAddr();
String key = "login:" + ip + ":" + username;
Bucket bucket = resolveBucket(key);
if (!bucket.tryConsume(1)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body("访问过于频繁,请稍后再试!");
}
// 判断是否需要验证码
if (isRequireCaptcha(username, ip)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("请完成验证码验证");
}
// 执行登录逻辑...
return ResponseEntity.ok("登录成功");
}
验证码策略入口点
private boolean isRequireCaptcha(String username, String ip) {
// 判断标准:连续失败次数超过2次或命中IP灰名单
Integer failCount = loginFailCache.getOrDefault(ip + ":" + username, 0);
return failCount >= 3 || grayIpList.contains(ip);
}
进阶防御能力(配合业务中台)
风控点 | 处理逻辑 |
---|---|
同IP高频登录 | 限制IP登录频率,封禁IP段或调拨流量 |
多账户同设备尝试 | 设备指纹识别,同设备异常换号报警 |
黑名单策略 | 多次失败即加入灰名单,所有请求强制验证码 |
登录成功后,failCount清零 | 防止误封合法用户 |
验证码推荐实现:
类型 | 说明 |
---|---|
图形验证码 | JCaptcha、Kaptcha |
滑动验证码 | 极验、腾讯验证码(用户体验好) |
短信验证码 | 绑定手机号后动态发送 |
优点分析:
- 行为风控 + 限流 + 验证码,三位一体,防爆破更高效
- 无状态限流,不依赖数据库或Redis(可落地+缓存混合)
- 限流组件(如Bucket4j、Resilience4j)性能稳定,线程安全
- 可接入日志分析系统,实时报警+行为建模
注意事项:
风险 | 建议 |
---|---|
滑动验证码第三方依赖 | 合理集成并设置超时时间,防止影响主业务 |
验证码被攻击(OCR) | 添加干扰、改滑动、短时令牌验证 |
滑动频率误杀正常用户 | 增加灰名单手动清理机制 + 黑白名单 |
总结一下:
这种方案适合大并发、大攻击面系统。尤其是当“业务+安全”联动成体系时——
- 限流组件 + 图形验证码
- 异常登录统计 + 账号冻结逻辑
- 黑名单维护 + 行为审计分析
你就不是简单做个登录功能,而是在构建一个“登录防线”。
你现在回过头来看这三种方案:
方案 | 特点 | 适用 |
---|---|---|
Redis临时锁 | 快速轻量、支持自动过期 | 一般场景、用户数不大 |
数据库持久锁 | 强一致性、审计友好 | 金融、电商、后台 |
限流+验证码联防 | 风控级别、防爆破 | 高并发系统、对抗攻击 |
如果你是在做SaaS平台、系统集成、门户登录系统或者啥政企大系统,这三种都要整合使用,不然早晚被“脚本仔”揍出心理阴影。