账号锁定配了"错 4 次锁 30 分钟",我连错 100 次一次没锁上:扒完 4091 行认证源码,找到 5 个坑

这是「ForgeAdmin 框架源码拆解」系列第 6 篇。上一篇拆的是 forge-starter-log(操作日志 1383 行),结尾我说下一篇要扒认证链路------因为当时发现登录日志里一条失败记录都没有。结果扒认证的时候,挖出了个比"没日志"严重得多的问题。


一、起因:一个"看起来很完善"的功能

事情是这样的。

我们项目的密码登录,配置里写得很清楚:

java 复制代码
private Boolean enableLoginLock = true;   // 开启登录锁定
private Integer maxLoginAttempts = 4;     // 最多错 4 次
private Long    lockDuration = 30L;       // 锁定 30 分钟
private Long    failRecordExpire = 15L;   // 失败记录保留 15 分钟

而且代码里确实有完整的锁定链路:ILoginLockService 接口 + LoginLockServiceImpl 实现 + Redis 计数 + Sa-Token 的 StpUtil.disable() 封禁。

上一篇我查登录日志的时候顺手想验证一下这个功能,就写了个脚本循环调用登录接口,密码故意写错。

连错了 100 次。

账号一次都没被锁。

每次都返回同一个错误:"用户名或密码错误",没有"还剩 3 次机会",没有"还剩 2 次",更没有"账号已锁定"。

我一开始以为配置没生效,去查了配置中心,是开的。

然后我去读了源码。


二、先看清整条链路

认证这块分两层:forge-starter-auth(4091 行,框架层,定义流程和抽象)+ forge-plugin-system/strategy(927 行,业务层,实现具体登录方式)。

scss 复制代码
                        ┌─────────────────────────────┐
   POST /auth/login ──▶ │      AuthController         │
                        └──────────────┬──────────────┘
                                       │ authType + userClient
                                       ▼
                        ┌─────────────────────────────┐
                        │    AuthStrategyFactory      │  精确匹配 client
                        │  ┌───────────────────────┐  │  > 类型匹配
                        │  │ ConcurrentHashMap     │  │  结果入缓存
                        │  │ authType_client → strat│  │
                        │  └───────────────────────┘  │
                        └──────────────┬──────────────┘
                                       ▼
        ┌──────────────────────────────────────────────────────┐
        │        AbstractAuthStrategy.authenticate()           │
        │                  【模板方法】                          │
        │  ① validateRequest()     子类实现参数校验              │
        │  ② doAuthenticate()      子类实现具体认证   ★坑在这    │
        │  ③ checkUserStatus()     检查禁用/锁定                 │
        │  ④ handleLoginSuccess()  清失败计数 + 打日志            │
        └──────────────────────────────────────────────────────┘
                                       │
        ┌──────────────────────────────┼──────────────────────────┐
        ▼                              ▼                          ▼
┌───────────────┐          ┌──────────────────┐        ┌──────────────────┐
│UsernamePasswd │          │UsernamePasswd    │        │PhoneCaptcha      │
│AuthStrategy   │          │CaptchaStrategy   │        │AuthStrategy      │
│    (42 行)    │          │    (182 行)      │        │    (47 行)       │
└───────────────┘          └──────────────────┘        └──────────────────┘
        │                                                        
        ▼                                                        
┌───────────────────────────────────────────────┐   
│  UserLoadServiceImpl                          │   
│  authenticateByUsernamePassword()             │   
│    └─ TenantContextHolder.executeIgnore(...)  │ ← 必须绕开租户过滤
│       userMapper.selectUsersByUsernameForLogin│   
│       ├─ 无候选     → throw RuntimeException  │ ★坑在这
│       ├─ 密码不匹配 → throw RuntimeException  │ ★坑在这
│       └─ 匹配成功   → 租户仲裁(多工作区要选)  │   
└───────────────────────────────────────────────┘   

认证类型一共 7 种:

java 复制代码
public enum AuthType {
    PASSWORD("password", "用户名密码认证"),
    PASSWORD_CAPTCHA("password_captcha", "用户名密码验证码认证"),
    PHONE_CAPTCHA("phone_captcha", "手机号验证码认证"),
    WECHAT("wechat", "微信授权登录"),
    EMAIL_CAPTCHA("email_captcha", "邮箱验证码认证"),
    OAUTH2("oauth2", "OAuth2认证"),
    SOCIAL("social", "社交登录");
}

三、核心接口:模板方法 + 策略工厂

这套设计本身是漂亮的,先看 AbstractAuthStrategy

java 复制代码
@Override
public LoginUser authenticate(LoginRequest request) {
    validateRequest(request);              // 1. 子类自定义参数校验
    LoginUser loginUser = doAuthenticate(request);  // 2. 子类实现认证
    checkUserStatus(loginUser);             // 3. 检查用户状态
    handleLoginSuccess(loginUser);          // 4. 成功后清计数
    return loginUser;
}

四个钩子,子类只需要实现 validateRequestdoAuthenticate。最简的 UsernamePasswordAuthStrategy 只有 42 行:

java 复制代码
@Component
public class UsernamePasswordAuthStrategy extends AbstractAuthStrategy {

    @Autowired
    private LoginPasswordDecoder loginPasswordDecoder;

    @Override
    protected void validateRequest(LoginRequest request) {
        validateUsername(request.getUsername());
        validatePassword(request.getPassword());
    }

    @Override
    protected LoginUser doAuthenticate(LoginRequest request) {
        String rawPassword = loginPasswordDecoder.decode(request.getPassword());
        LoginUser loginUser = userLoadService.authenticateByUsernamePassword(
                request.getUsername(), rawPassword, request.getTenantId());
        checkAccountLocked(loginUser);
        if (loginUser == null) {                        // ← 第 32 行
            recordLoginFailure(null, "用户名或密码错误");   // ← 第 33 行
        }
        return loginUser;
    }

    @Override
    public String getAuthType() {
        return AuthType.PASSWORD.getCode();
    }
}

工厂的匹配逻辑也值得学------精确匹配优先于兜底匹配

java 复制代码
private IAuthStrategy findMatchingStrategy(String authType, String userClient) {
    IAuthStrategy typeOnlyMatch = null;
    for (IAuthStrategy strategy : authStrategies) {
        if (!authType.equals(strategy.getAuthType())) continue;
        
        String supportedClient = strategy.getSupportedClient();
        if (supportedClient == null) {      // 支持所有客户端 → 兜底候选
            typeOnlyMatch = strategy;
            continue;
        }
        if (userClient != null && supportedClient.equals(userClient)) {
            return strategy;                // 精确匹配,直接返回
        }
    }
    return typeOnlyMatch;                   // 没有精确匹配,用兜底
}

配合 ConcurrentHashMap 缓存 authType_client → strategy,避免每次遍历。这些都是对的设计。

但就在这 42 行里,藏着一个让整个账号锁定功能失效的问题。


四、坑 1(高危):账号锁定在密码错误时完全不生效

看上面第 29-34 行。逻辑意图很清楚:

  1. authenticateByUsernamePassword 拿用户
  2. 检查账号是否被锁
  3. 如果拿到的用户是 null → 记一次失败

问题在于第 1 步永远不会返回 null

我去翻了实现:

java 复制代码
@Override
public LoginUser authenticateByUsernamePassword(String username, String rawPassword, Long requestedTenantId) {
    List<SysUser> candidates = TenantContextHolder.executeIgnore(() ->
            userMapper.selectUsersByUsernameForLogin(username));
    if (CollUtil.isEmpty(candidates)) {
        throw new RuntimeException("用户名或密码错误");      // ← 抛异常,不是返回 null
    }

    List<SysUser> matched = candidates.stream()
            .filter(user -> matchPassword(rawPassword, user.getPassword()))
            .toList();
    if (matched.isEmpty()) {
        throw new RuntimeException("用户名或密码错误");      // ← 抛异常,不是返回 null
    }
    // ... 租户仲裁
}

密码错了它抛异常,不返回 null。

于是执行流根本走不到第 31 行,直接被全局异常处理器接走,返回"用户名或密码错误"。

那个 if (loginUser == null)死代码,永远不会进入。

更绝的是:就算进去了也没用

假设我们改了认证方法,让它返回 null。看 recordLoginFailure

java 复制代码
protected void recordLoginFailure(LoginUser loginUser, String errorMessage) {
    if (!isLoginLockEnabled() || loginUser == null) {
        throw new RuntimeException(errorMessage);   // ← 传 null,第一行就抛
    }
    int remaining = loginLockService.recordLoginFailure(loginUser);
    // ...
}

调用点传的是 nullloginUser == null 成立 → 直接抛异常,不计数、不锁定

所以这条链路上有两道保险,两道都是坏的

结论:密码输错一万次,账号也不会被锁。暴力破解防护形同虚设。

根因

不是"忘记调用",是用异常表达了失败路径

一旦失败以异常的形式抛出,调用方所有后续动作(计数、锁定、审计、告警)全部被跳过。而异常又会被最外层的统一处理器接走,返回一句无关痛痒的提示------从外部看,一切正常。

修法

让失败成为可判定的返回值,而不是控制流。

java 复制代码
// 1) 认证方法:失败返回结果对象,不抛异常
public sealed interface AuthResult permits AuthResult.Success, AuthResult.Failed {
    record Success(LoginUser user) implements AuthResult {}
    record Failed(FailReason reason) implements AuthResult {}
}
public enum FailReason { USER_NOT_FOUND, BAD_PASSWORD, TENANT_MISMATCH }
java 复制代码
// 2) doAuthenticate:按失败原因分别处理
@Override
protected LoginUser doAuthenticate(LoginRequest request) {
    String rawPassword = loginPasswordDecoder.decode(request.getPassword());

    // 先只按用户名查,不验密码------拿到主体才能计数
    LoginUser principal = userLoadService.findByUsername(request.getUsername());
    checkAccountLocked(principal);                    // 锁定检查前置

    AuthResult result = userLoadService.authenticateByUsernamePassword(
            request.getUsername(), rawPassword, request.getTenantId());

    if (result instanceof AuthResult.Failed(FailReason reason)) {
        // 只有"用户存在但密码错"才计数------用户不存在不计数
        if (reason == FailReason.BAD_PASSWORD && principal != null) {
            recordLoginFailure(principal, "用户名或密码错误");   // 传真实用户
        }
        throw new RuntimeException("用户名或密码错误");
    }
    return ((AuthResult.Success) result).user();
}
java 复制代码
// 3) recordLoginFailure:不再接受 null,null 直接编译不过
protected void recordLoginFailure(@NonNull LoginUser loginUser, String errorMessage) {
    Objects.requireNonNull(loginUser, "计数必须基于真实用户");
    if (!isLoginLockEnabled()) {
        throw new IllegalStateException("登录锁定未启用,不应调用计数");
    }
    int remaining = loginLockService.recordLoginFailure(loginUser);
    throw new RuntimeException(remaining > 0
            ? String.format("%s,还剩 %d 次尝试机会", errorMessage, remaining)
            : String.format("%s,账号已被锁定 %d 分钟", errorMessage, authProperties.getLockDuration()));
}

@NonNull + requireNonNull 让"传 null"在编译/Runtime 就暴露,而不是静默变成不计数。


五、坑 2:LOGIN_LOCK_KEY_PREFIX 是个从未被写入的死 key

LoginLockServiceImpl 顶部声明了两个前缀:

java 复制代码
private static final String LOGIN_FAIL_KEY_PREFIX = "login:fail:";
private static final String LOGIN_LOCK_KEY_PREFIX = "login:lock:";   // ← 这个

全项目搜一遍 LOGIN_LOCK_KEY_PREFIX 的使用:

java 复制代码
@Override
public void clearLoginFailure(Long userId) {
    String failKey = LOGIN_FAIL_KEY_PREFIX + userId;
    String lockKey = LOGIN_LOCK_KEY_PREFIX + userId;
    
    stringRedisTemplate.delete(failKey);
    stringRedisTemplate.delete(lockKey);     // ← 删了一个从来没写过的 key
}

只有一次使用,还是在删除逻辑里。

从来没有任何代码往 login:lock:{userId} 写过东西。真正的锁定走的是 Sa-Token:

java 复制代码
if (failCount >= maxAttempts) {
    StpUtil.disable(loginUser.getUserId(), lockDuration * 60);   // 存在 Sa-Token 自己的 key 里
    return 0;
}

所以 clearLoginFailure 里的 delete(lockKey) 是一次永远删空的删除。它不报错、不影响功能,所以活了很久。

这类"死常量"比死代码更隐蔽------死代码至少逻辑上能看出来,死常量看起来"配置得很完整"。

检测办法:IDEA 里对常量 Find Usages,如果只有声明 + 删除,没有写入,就是死 key。或者上 ArchUnit 写一条规则扫。


六、坑 3:计数窗口比锁定窗口短,防护被稀释

看这组默认值:

java 复制代码
private Integer maxLoginAttempts = 4;      // 错 4 次就锁
private Long    lockDuration = 30L;        // 锁 30 分钟
private Long    failRecordExpire = 15L;    // 但失败计数只保留 15 分钟

计数 key 的过期是第一次失败时设置的

java 复制代码
Long failCount = stringRedisTemplate.opsForValue().increment(failKey);
if (failCount == 1) {
    stringRedisTemplate.expire(failKey, authProperties.getFailRecordExpire(), TimeUnit.MINUTES);
}

时间线推演:

r 复制代码
T+0min   第 1 次失败  failCount=1  设置 15 分钟过期
T+1min   第 4 次失败  failCount=4  → 触发锁定,封禁 30 分钟
T+16min  failKey 过期,failCount 清零(账号还锁着,16 < 30)
T+30min  封禁解除
T+30min  攻击者重新获得完整 4 次机会

也就是说锁定期间计数就已经被清了,解锁后不是"还剩 0 次",而是重置为 0 次失败、可以再来 4 次。

正确做法:计数 key 的过期时间应当 ≥ 锁定时间,并且在解锁时(而不是登录成功时)才清除:

java 复制代码
// 计数窗口覆盖锁定窗口 + 缓冲
stringRedisTemplate.expire(failKey, lockDuration + 5, TimeUnit.MINUTES);

或者更干净:锁定后 failKey 不设过期,只在 unlock() / 登录成功时删。


七、坑 4:安全开关是 fail-open 的

看这个方法:

java 复制代码
protected boolean isLoginLockEnabled() {
    return authProperties.getEnableLoginLock() != null
            && authProperties.getEnableLoginLock()
            && loginLockService != null;
}

loginLockService 是这么注入的:

java 复制代码
@Autowired(required = false)
protected ILoginLockService loginLockService;

@Autowired(required = false) 意味着没有这个 Bean 也能正常启动

如果有人排除了自动配置、或者自己实现时漏了 @ServiceloginLockService 就是 nullisLoginLockEnabled() 返回 falsecheckAccountLocked 直接 returnrecordLoginFailure 直接抛原始异常。

系统照常运行,登录照常成功,只是防护没了。没有任何日志。

安全相关的开关,默认值应该是 fail-close:

java 复制代码
@Autowired(required = false)
protected ILoginLockService loginLockService;

@PostConstruct
void checkLockService() {
    if (Boolean.TRUE.equals(authProperties.getEnableLoginLock()) && loginLockService == null) {
        // 明确开启却拿不到实现 → 启动失败,别带病上线
        throw new IllegalStateException(
            "已开启 enableLoginLock,但未找到 ILoginLockService 实现,请检查配置");
    }
}

宁可起不来,也别"看起来起来了"。


八、坑 5:失败提示泄露剩余次数

java 复制代码
int remaining = loginLockService.recordLoginFailure(loginUser);
if (remaining > 0) {
    throw new RuntimeException(String.format("%s,还剩 %d 次尝试机会", errorMessage, remaining));
}

"还剩 3 次尝试机会"------这个提示对真实用户友好,但对攻击者是免费的反馈通道

  • 返回"还剩 3 次" → 说明用户名存在、密码错误
  • 返回"用户不存在"或完全不同的文案 → 说明用户名不存在

这就是典型的用户名枚举漏洞。攻击者可以用它先批量确认哪些用户名有效,再针对性撞库。

而且配合坑 3(解锁后计数重置),攻击者可以:试 3 次 → 等 30 分钟 → 再来 3 次,无限循环。

权衡下来我的建议是:

java 复制代码
// 对外统一文案,不区分用户是否存在、不暴露剩余次数
throw new RuntimeException("用户名或密码错误");
// 剩余次数只进日志/审计,不回前端
log.warn("用户 {} 登录失败,剩余 {} 次", loginUser.getUsername(), remaining);

用户体验上损失一点,安全性上补回来。真要做"还剩几次",也得配合图形验证码------有验证码在,暴露次数就没那么危险。


九、它做得对的地方:登录必须绕开租户过滤

扒了这么多坑,也得说个真正漂亮的设计。

登录时查用户,外面包了一层:

java 复制代码
List<SysUser> candidates = TenantContextHolder.executeIgnore(() ->
        userMapper.selectUsersByUsernameForLogin(username));

为什么登录必须绕开租户过滤?

因为登录的时候你还不知道这个用户属于哪个租户。如果带着租户条件去查,要么用默认的租户 ID 查(跨租户用户永远登录不了),要么查不到。

(这正是本系列第 2 篇拆 tenant Starter 时讲过的 executeIgnore 机制------当时是当"逃生舱"讲的,这里就是它的刚需场景。)

拿到跨租户的同名候选之后,再做仲裁:

java 复制代码
if (requestedTenantId != null) {
    SysUser matchedUser = findUserForTenant(matched, requestedTenantId);
    if (matchedUser == null) throw new RuntimeException("用户名或密码错误");
    return loadUserByUserId(matchedUser.getId(), requestedTenantId);
}

List<LoginTenantOption> options = collectWorkspaceOptions(matched);
if (options.size() == 1) {
    // 只属于一个工作区,直接进
    return loadUserByUserId(matched.get(0).getId(), options.get(0).getTenantId());
}
// 属于多个工作区,让用户自己选
throw new BusinessException(AuthResultCodes.TENANT_SELECTION_REQUIRED, "请选择要进入的工作区", options);

"一个人在多个租户存在"这个场景,很多系统的处理方式是直接报错或者随便选一个。这里做成了让用户选工作区,体验好很多。

而且注意一个细节:密码比对是对所有同名候选逐个执行 的(candidates.stream().filter(matchPassword))。这意味着同一个用户名在不同租户可以有不同的密码,都能登录成功------避免了"同名用户密码冲突"的诡异问题。


十、五个坑速查表

# 现象 根因 解法
1 账号锁定完全失效 密码错 100 次也不锁 认证失败抛异常 → if (loginUser == null) 是死代码;且 recordLoginFailure(null,...) 第一行就 throw 失败改为返回值(sealed interface / Optional),计数方法不接受 null
2 死常量 key 一直在删一个不存在的 key LOGIN_LOCK_KEY_PREFIX 只有声明和删除,无写入 Find Usages 体检;写入方缺失的常量要么补要么删
3 计数窗口 < 锁定窗口 解锁后重新获得完整机会 failRecordExpire(15) < lockDuration(30) 计数过期 ≥ 锁定时长;解锁时才清计数
4 安全开关 fail-open Bean 缺失时防护静默消失 @Autowired(required=false) + isLoginLockEnabled() 静默返回 false 显式开启却无实现 → 启动失败
5 提示泄露剩余次数 可枚举用户名 + 无限重试 "还剩 N 次机会"给了攻击者反馈 对外统一文案,次数只进审计日志

十一、可带走的 6 条诀窍

  1. 失败路径不要用异常表达。 一旦用异常,后续所有动作(计数、锁定、审计、告警)都会被跳过,而且异常会被引擎接走变成一句无关痛痒的提示------从外面看一切正常。这是我在这个项目里连着踩到两次的坑(幂等那篇也是)。

  2. 安全开关默认 fail-close。 显式开启的能力拿不到实现,应该让应用起不来,而不是降级成"没防护"。

  3. 计数窗口必须 ≥ 锁定窗口。 否则锁定结束的那一刻,攻击者的配额已经偷偷重置了。

  4. 对外错误提示要"粒度一致"。 "用户不存在"和"密码错误"必须返回同一句话,"还剩 N 次"看起来友好,实际是给攻击者的计数器。

  5. 涉及身份的查询要想清楚要不要走租户过滤。 登录/找回密码/按手机号查人这类场景,租户信息本身就是待确定的,必须显式绕开------但要像这个项目一样用 executeIgnore(() -> ...) 包出明确边界,而不是偷偷关掉。

  6. 给"死常量"做体检。 声明了、只在删除逻辑里用过的 key 前缀,是重构后最容易留下的垃圾。IDE 的 Find Usages 查一次只要 10 秒。


十二、最后

这个功能从代码上看是完整的:有接口、有实现、有配置、有 Redis、有封禁调用、有解锁入口。

Code Review 也过得去------checkAccountLockedrecordLoginFailure 都在,逻辑读起来也通顺。

唯一的问题是它从来没被执行过。

而验证它只需要 30 秒:写个 for 循环错 5 次密码,看账号锁不锁。

我后来问了一圈,很少有人真的测过自己项目的账号锁定。大家都是"配上了就觉得有了"。

所以这篇如果你只带走一件事:去测一下你们系统的账号锁定,现在,就现在。 配置写了不等于生效,尤其是在用了框架的情况下------框架给你的是能力,不是保证。


如果这篇对你有启发,点个赞吧,这是系列第 6 篇,挖这些坑都得真读源码。

下一篇我们扒 forge-starter-excel(4223 行,30 个类),那里有个更经典的:@Async 自调用导致"异步导出"其实是同步的,代码看着完全没问题,也不报错。

评论区聊聊:你们项目的账号锁定功能,有人真的测过吗? 我赌一半人没测过。


系列导航

  1. 数据权限拦截器:SQL 改写那些坑
  2. 多租户 Starter 源码拆解
  3. 多租户 × 数据权限共存:拦截器注册顺序
  4. 幂等 Starter:1279 行里的 5 个隐蔽坑
  5. 操作日志 Starter:1383 行里的 5 个反直觉设计
  6. 认证链路:4091 行,账号锁定为什么完全失效(本篇)

项目地址

  • Gitee:https://gitee.com/ForgeLab/forge-admin
  • GitHub:https://github.com/yaomindong1996/forge-admin
  • 在线文档:http://www.dlforgelab.com:8084/forge-docs/
  • 在线演示:http://www.dlforgelab.com:8084/forge/login(admin / 123456)

源码路径:forge-server/forge-framework/forge-starter-parent/forge-starter-auth(4091 行)、forge-plugin-system/src/main/java/.../strategy(927 行)。

文中提到的坑 1~5 均为阅读源码所得,修复方案为本文建议,尚未提交到仓库。

相关推荐
Methy1 小时前
一次线上死锁排查:std::list::size () 居然是 O (n)?
c++·后端
程序猿乐锅1 小时前
【黑马点评 | 第二篇】Redis 缓存更新策略与商铺缓存实现
java·网络·redis·spring·mybatis
livemetee1 小时前
MySQL联合唯一索引(code,deleted)与逻辑删除问题
java·数据库
MacroZheng1 小时前
Redis官方发布高颜值可视化工具,功能更是强的离谱!
java·redis·后端
wuminyu1 小时前
纯轻量级锁体系下C2编译器锁粗化和消除机制剖析
java·linux·c语言·jvm·c++
ss2731 小时前
AI全栈实战 | 1.4-02 Spring AOP:@Transactional 失效的 N 种场景,根因其实只有一个
java·spring·ai编程
回家路上绕了弯1 小时前
为什么 AI 写代码时,总喜欢“防御性编程”?
后端
灯澜忆梦1 小时前
【Redis中间件】#4 | 进阶数据类型语法
redis·后端
广州山泉婚姻1 小时前
前后端分离的核心优势
前端·后端