这是「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;
}
四个钩子,子类只需要实现 validateRequest 和 doAuthenticate。最简的 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 行。逻辑意图很清楚:
- 调
authenticateByUsernamePassword拿用户 - 检查账号是否被锁
- 如果拿到的用户是
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);
// ...
}
调用点传的是 null → loginUser == 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 也能正常启动。
如果有人排除了自动配置、或者自己实现时漏了 @Service,loginLockService 就是 null → isLoginLockEnabled() 返回 false → checkAccountLocked 直接 return,recordLoginFailure 直接抛原始异常。
系统照常运行,登录照常成功,只是防护没了。没有任何日志。
安全相关的开关,默认值应该是 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 条诀窍
-
失败路径不要用异常表达。 一旦用异常,后续所有动作(计数、锁定、审计、告警)都会被跳过,而且异常会被引擎接走变成一句无关痛痒的提示------从外面看一切正常。这是我在这个项目里连着踩到两次的坑(幂等那篇也是)。
-
安全开关默认 fail-close。 显式开启的能力拿不到实现,应该让应用起不来,而不是降级成"没防护"。
-
计数窗口必须 ≥ 锁定窗口。 否则锁定结束的那一刻,攻击者的配额已经偷偷重置了。
-
对外错误提示要"粒度一致"。 "用户不存在"和"密码错误"必须返回同一句话,"还剩 N 次"看起来友好,实际是给攻击者的计数器。
-
涉及身份的查询要想清楚要不要走租户过滤。 登录/找回密码/按手机号查人这类场景,租户信息本身就是待确定的,必须显式绕开------但要像这个项目一样用
executeIgnore(() -> ...)包出明确边界,而不是偷偷关掉。 -
给"死常量"做体检。 声明了、只在删除逻辑里用过的 key 前缀,是重构后最容易留下的垃圾。IDE 的 Find Usages 查一次只要 10 秒。
十二、最后
这个功能从代码上看是完整的:有接口、有实现、有配置、有 Redis、有封禁调用、有解锁入口。
Code Review 也过得去------checkAccountLocked 和 recordLoginFailure 都在,逻辑读起来也通顺。
唯一的问题是它从来没被执行过。
而验证它只需要 30 秒:写个 for 循环错 5 次密码,看账号锁不锁。
我后来问了一圈,很少有人真的测过自己项目的账号锁定。大家都是"配上了就觉得有了"。
所以这篇如果你只带走一件事:去测一下你们系统的账号锁定,现在,就现在。 配置写了不等于生效,尤其是在用了框架的情况下------框架给你的是能力,不是保证。
如果这篇对你有启发,点个赞吧,这是系列第 6 篇,挖这些坑都得真读源码。
下一篇我们扒 forge-starter-excel(4223 行,30 个类),那里有个更经典的:@Async 自调用导致"异步导出"其实是同步的,代码看着完全没问题,也不报错。
评论区聊聊:你们项目的账号锁定功能,有人真的测过吗? 我赌一半人没测过。
系列导航
- 数据权限拦截器:SQL 改写那些坑
- 多租户 Starter 源码拆解
- 多租户 × 数据权限共存:拦截器注册顺序
- 幂等 Starter:1279 行里的 5 个隐蔽坑
- 操作日志 Starter:1383 行里的 5 个反直觉设计
- 认证链路: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 均为阅读源码所得,修复方案为本文建议,尚未提交到仓库。