安全同事找我要"上周所有登录失败记录",我查完数据库:一条都没有

系列第 5 篇,兑现上一篇的预告,拆 forge-starter-log(1383 行)。

他问的时候我还挺自信------我们登录日志、操作日志都有,查个失败记录不是分分钟的事?

然后我写了条 SQL:SELECT * FROM sys_login_log WHERE login_status = 0

返回 0 行。

我又查了全表,几万条记录,没有一条是失败的


一、先说结论,再拆代码

我们的登录日志有 6 种类型:LOGIN、LOGOUT、KICKOUT、REPLACED、DISABLE、UNTIE_DISABLE。

看着挺全对吧?但它们全部只在"成功"时记录

原因藏在 Sa-Token 的监听机制里。往下看。


二、模块全景

整个 starter 只有 1383 行,7 个类,两条完全独立的链路:

perl 复制代码
forge-starter-log/
├── aspect/
│   └── OperationLogAspect.java        # 676 行 ------ 操作日志(链路 A:AOP 切面)
├── listener/
│   └── LoginLogListener.java          # 登录日志(链路 B:SaTokenListener 事件)
├── context/
│   └── OperationAuditContext.java     # 审计快照(普通 ThreadLocal)
├── config/
│   └── LogThreadPoolConfig.java       # 共用线程池
├── domain/
│   ├── OperationLogInfo.java          # 操作日志实体(23 个字段)
│   └── LoginLogInfo.java              # 登录日志实体
├── service/
│   └── ILogService.java               # ← 持久化 SPI,框架层不实现
└── (无 Mapper、无 Entity、无 SQL)
less 复制代码
链路 A:操作日志
HTTP 请求 → OperationLogAspect(@Around) → 构造 OperationLogInfo → 异步线程池 → ILogService.saveOperationLog

链路 B:登录日志
StpUtil.login() → Sa-Token 事件 → LoginLogListener.doLogin() → 构造 LoginLogInfo → 异步线程池 → ILogService.saveLoginLog

两条链路共用同一个线程池:logTaskExecutor
两条链路共用同一个持久化出口:ILogService

三、先看设计得好的地方:框架层不碰数据库

这是整个模块最值得学的一点。ILogService 只有两个方法:

java 复制代码
public interface ILogService {
    void saveOperationLog(OperationLogInfo logInfo);
    void saveLoginLog(LoginLogInfo logInfo);
}

而框架层没有任何实现类。两个入口组件都是这么注册的:

java 复制代码
@Aspect
@Component
@ConditionalOnBean(ILogService.class)     // ← 关键
public class OperationLogAspect { ... }

@Component
@ConditionalOnBean(ILogService.class)     // ← 同样
public class LoginLogListener implements SaTokenListener { ... }

业务方不实现 ILogService,这两个 Bean 根本不会注册,切面和监听器都不会进 Spring 容器,连 AOP 代理都不会创建。

对比一下常见写法:starter 里塞一个默认实现,写死一张表,业务方要么接受你的表结构,要么自己写一堆排除逻辑。这里的选择干净得多------框架定义契约,业务决定落地

forge-plugin-system 里的实现长这样:

java 复制代码
@Service
public class SystemLogServiceImpl implements ILogService {
    @Override
    public void saveLoginLog(LoginLogInfo logInfo) {
        fillLoginLogUserInfo(logInfo);      // 补全用户名、部门等冗余字段
        loginLogMapper.insert(convert(logInfo));
    }
}

四、5 个反直觉的设计

设计 1:切点拦的是所有 Controller,不是 @OperationLog

注解挂在 @OperationLog 上,那切点应该是 @annotation 吧?看代码:

java 复制代码
/**
 * 定义切点:拦截所有标注@OperationLog的方法
 */
//@Pointcut("@annotation(com.mdframe.forge.starter.core.annotation.log.OperationLog)")
@Pointcut("@within(org.springframework.stereotype.Controller)
       || @within(org.springframework.web.bind.annotation.RestController)")
public void operationLogPointcut() {
}

@annotation 那行被注释掉了 ,实际生效的是 @within(@Controller) || @within(@RestController)

注释还停留在"拦截所有标注@OperationLog的方法"------注释和代码已经对不上了

后果:只要类上有 @RestController,这个类里的每个方法都会进切面 ,不管你有没有加 @OperationLog。你以为只给 3 个接口加了日志,实际是整个项目的接口都被拦了一遍。

为什么这么改?因为下一步它会"自动推断"要不要记、记成什么类型。这确实方便------但同时意味着每个请求都要付一次切面进入的成本


设计 2:决定"不记"之前,活已经干完了

切面里的顺序是这样的:

java 复制代码
// 1. 拿 request
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();

// 2. 判断是否排除路径(遍历 8 项敏感路径 + 正则匹配配置的排除路径)
if (isExcludePath(requestUrl)) {
    return joinPoint.proceed();
}

// 3. 构造日志对象,填 6 个字段
OperationLogInfo logInfo = new OperationLogInfo();
logInfo.setOperationTime(LocalDateTime.now());
logInfo.setRequestUrl(requestUrl);
logInfo.setRequestMethod(request.getMethod());
logInfo.setOperationIp(getClientIp(request));
logInfo.setUserAgent(request.getHeader("User-Agent"));
logInfo.setOperationPage(...);
logInfo.setOperationPageTitle(...);

// 4. 没加注解的话,去查一遍 API 配置
OperationLog annotation = method.getAnnotation(OperationLog.class);
if (annotation != null) {
    // 用注解上的信息
} else {
    apiConfig = apiConfigManager.getApiConfig(request.getRequestURI(), request.getMethod());
}

// 5. 到这里才判断:这条到底要不要记?
if (shouldSkipOperationLog(logInfo.getOperationType(), request.getMethod())) {
    return proceedWithoutOperationLog(joinPoint);
}

先做饭,再问你饿不饿。

第 4 步是重点。所有没加 @OperationLog 的接口(也就是绝大多数),每个请求都要走一次 apiConfigManager.getApiConfig()。你的列表查询、下拉框、树形菜单------这些压根不该记日志的高频 GET 请求,全都在查配置。

合理的顺序应该是:先做便宜的判断 (是不是 GET、URL 是不是查询类后缀),再做贵的(构造对象、查配置)。


设计 3:操作日志和登录日志抢同一个只有 2 个线程的池子

java 复制代码
@Bean("logTaskExecutor")
public Executor logTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(logProperties.getThreadPoolCoreSize());      // 默认 2
    executor.setMaxPoolSize(logProperties.getThreadPoolMaxSize());        // 默认 5
    executor.setQueueCapacity(logProperties.getThreadPoolQueueCapacity());// 默认 500
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(60);
    return executor;
}

两个问题叠在一起:

问题 A:池子太小。 core=2、max=5,最多 5 个线程写日志。

问题 B:两条链路共用它。 早上 9 点全员打卡登录,登录日志瞬间涌入;同时业务操作也在产生操作日志。两边抢 5 个线程。

问题 C(最致命):CallerRunsPolicy 队列 500 满之后,提交任务的业务线程自己执行写日志

于是就形成了正反馈:

markdown 复制代码
高峰期 → 日志堆积 → 队列满 → 业务线程被迫自己写日志 → 接口变慢
      → 请求堆积 → 更多日志 → 更多业务线程被拖下水

我见过最典型的现象是"系统下午两三点特别卡,其他时间正常"。查 CPU、内存、慢 SQL 全都正常,因为瓶颈根本不在那儿。

解法

yaml 复制代码
forge:
  log:
    thread-pool-core-size: 8
    thread-pool-max-size: 16
    thread-pool-queue-capacity: 5000

再把拒绝策略换掉。日志是旁路任务,丢几条可以接受,把业务线程拖死不行

java 复制代码
// 丢弃 + 计数告警,比 CallerRunsPolicy 适合旁路场景
executor.setRejectedExecutionHandler((r, ex) -> {
    logDiscardCounter.increment();
    log.warn("日志队列已满,丢弃一条日志,当前丢弃总数: {}", logDiscardCounter.get());
});

设计 4:登录日志里永远没有"失败"

回到开头那个问题。

LoginLogListener 实现的是 Sa-Token 的 SaTokenListener,6 个事件方法:

java 复制代码
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
    LoginLogInfo logInfo = buildLoginLog(loginId, "LOGIN", 1, "登录成功");   // ← 状态码写死 1
    saveLoginLogAsync(logInfo);
}

@Override
public void doLogout(...)      { buildLoginLog(loginId, "LOGOUT", 1, "登出成功"); }
@Override
public void doKickout(...)     { buildLoginLog(loginId, "KICKOUT", 1, "被踢下线"); }
@Override
public void doReplaced(...)    { buildLoginLog(loginId, "REPLACED", 1, "被顶下线"); }
@Override
public void doDisable(...)     { buildLoginLog(loginId, "DISABLE", 0, "账号被封禁"); }
@Override
public void doUntieDisable(...) { buildLoginLog(loginId, "UNTIE_DISABLE", 1, "账号解封"); }

doLogin 只在登录成功时才会被 Sa-Token 回调。 密码错了?压根不触发这个监听器。

那失败去哪了?在认证策略里:

java 复制代码
// AbstractAuthStrategy
protected void recordLoginFailure(LoginUser loginUser, String errorMessage) {
    if (!isLoginLockEnabled() || loginUser == null) {
        throw new RuntimeException(errorMessage);
    }
    int remaining = loginLockService.recordLoginFailure(loginUser);   // ← 只记了失败次数
    if (remaining > 0) {
        throw new RuntimeException(String.format("%s,还剩 %d 次尝试机会", errorMessage, remaining));
    } else {
        throw new RuntimeException(String.format("%s,账号已被锁定 %d 分钟", ...));
    }
}

失败次数进了 loginLockService(用来做账号锁定),但没有任何一行代码把它写进 sys_login_log

所以:

场景 进 login_lock? 进 sys_login_log?
登录成功 ---
密码错误 ---
用户不存在 ---
账号被锁定 ---
被踢下线 ---

结果是:你的登录日志表只能回答"谁登录过",回答不了"谁在尝试攻击我们"。

而"记录登录失败"恰恰是等保三级里明明白白写着的审计要求。

解法 :让 recordLoginFailure 同时落一条登录日志:

java 复制代码
protected void recordLoginFailure(LoginUser loginUser, String errorMessage) {
    // 补一条失败日志(loginUser 可能为 null,用请求里的用户名兜底)
    LoginLogInfo failLog = new LoginLogInfo();
    failLog.setLoginType("LOGIN");
    failLog.setLoginStatus(0);
    failLog.setLoginMessage(errorMessage);
    failLog.setLoginTime(LocalDateTime.now());
    if (loginUser != null) {
        failLog.setUserId(loginUser.getUserId());
        failLog.setUsername(loginUser.getUsername());
    }
    fillFromRequest(failLog);
    saveLoginLogAsync(failLog);

    if (!isLoginLockEnabled() || loginUser == null) {
        throw new RuntimeException(errorMessage);
    }
    // ... 原有锁定逻辑
}

设计 5:traceId 只进 MDC,不进数据库

切面里生成了 traceId:

java 复制代码
private static final String TRACE_ID_KEY = "traceId";

String traceId = generateTraceId();
MDC.put(TRACE_ID_KEY, traceId);

然后呢?我翻遍了 OperationLogInfo 的 23 个字段:

java 复制代码
private Long id;
private Long tenantId;
private Long userId;
private String username;
private String operatorName;
private String operationModule;
private String operationType;
private String operationDesc;
private String operationPage;
private String operationPageTitle;
private String operationContent;
private String requestMethod;
private String requestUrl;
private String requestParams;
private String responseResult;
private String beforeData;
private String afterData;
private String diffData;
private String errorMsg;
private Integer operationStatus;
private String operationIp;
private String operationLocation;
private String userAgent;
private Long executeTime;
private LocalDateTime operationTime;

没有 traceId。

它只活在 MDC 里,也就是只会出现在日志文件的那一行。数据库里的日志记录和文件日志对不上号 。排查问题的时候,你只能靠 operation_time 加 IP 去猜。

更尴尬的是这段代码:

java 复制代码
} finally {
    saveLogAsync(logInfo);                    // ① 提交异步任务
    OperationAuditContext.clear();
    MDC.remove(TRACE_ID_KEY);                 // ② 紧接着就清掉了
}

saveLogAsync 只是把任务丢进线程池,线程真正开始执行时,主线程的 MDC 已经被清空了。MDC 默认不是 InheritableThreadLocal,异步线程拿不到 traceId。

结果:写日志的那个线程,既打不出 traceId,也存不进 traceId。

解法 :把 traceId 作为一个字段塞进 logInfo,在提交异步任务之前就填好。这样数据库里有、MDC 里也有,两边能对上。

java 复制代码
logInfo.setTraceId(traceId);   // 在 MDC.put 之后、saveLogAsync 之前

五、补充:4 个方法漏了开关检查

LogProperties 有个开关:

java 复制代码
private Boolean enableLoginLog = true;

doLogindoLogout 老老实实检查了:

java 复制代码
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
    if (logProperties.getEnableLoginLog() == null || !logProperties.getEnableLoginLog()) {
        return;
    }
    ...
}

doKickoutdoReplaceddoDisabledoUntieDisable 四个方法一个都没检查

forge.log.enable-login-log 设成 false 之后,登录/登出不记了,踢下线、顶下线、封禁、解封照样在记。

这个不一致不会造成故障,但会让人困惑------"我明明关了登录日志,怎么表还在涨?"


六、几个做得对的细节

吐槽完了,说点值得抄的。

敏感路径整个不进切面。

java 复制代码
private static final Set<String> SENSITIVE_CREDENTIAL_PATH_SUFFIXES = Set.of(
        "/auth/login",
        "/auth/register",
        "/auth/changePassword",
        "/auth/resetPassword",
        "/auth/resetPassword/code",
        "/auth/online/kickout",
        "/auth/online/batchKickout",
        "/oauth2/token"
);

登录、改密、踢人下线这些接口,请求体里有明文密码或 Token。它们连切面都不进,从源头上杜绝了"密码被写进日志表"。

开了接口加密的参数会被替换掉。

java 复制代码
if (redactRequestBody && hasParameterAnnotation(signature.getMethod(), i, RequestBody.class)) {
    params.put(paramName, "[DECRYPTED_REQUEST_BODY_OMITTED]");
}

@RequestBody 的参数,只要接口标了 @ApiDecrypt,日志里存的是占位符。而且后面还有一道防线:

java 复制代码
private boolean containsOmittedRequestBody(String value) {
    return StrUtil.isNotBlank(value) && value.contains(DECRYPTED_REQUEST_BODY_OMITTED);
}

兜底填 afterData 的时候,还要再检查一次,防止把占位符当成真实数据存进去。

没加注解也能自动分类操作类型。

java 复制代码
private String resolveDefaultOperationType(String requestMethod, String requestUrl) {
    if (isReadOnlyMethod(requestMethod)) return "QUERY";
    if (isQueryLikeRequest(requestUrl)) return "QUERY";      // /page /list /tree /detail /getbyid /options /profile /query
    if (isDeleteLikeRequest(requestUrl)) return "DELETE";    // /remove /delete /deletebatch
    if (isAddLikeRequest(requestUrl)) return "ADD";          // /add /create /import
    return "UPDATE";
}

这是"约定优于配置"------只要你 URL 命名规范,不加注解也知道是增删改查里的哪一种。比要求每个开发都记得加注解靠谱得多。

QUERY 全部不记。

java 复制代码
if ("QUERY".equals(operationType)) {
    return true;    // 跳过
}

查询量是写操作的几十上百倍,全记下来数据库撑不住。这个取舍是对的。

审计快照在提交异步前就拷贝好了。

OperationAuditContext 用的是普通 ThreadLocal(不是 TTL):

java 复制代码
private static final ThreadLocal<Snapshot> HOLDER = ThreadLocal.withInitial(Snapshot::new);

如果切面在异步线程里读它,必然读到空。但代码是这么处理的------在主线程就把快照值拷进 POJO

java 复制代码
fillAuditSnapshot(logInfo, requestParams, responseResult);   // 主线程:ThreadLocal → POJO
...
} finally {
    saveLogAsync(logInfo);                                    // 之后才提交异步
    OperationAuditContext.clear();
}

异步线程拿到的 logInfo 已经是个纯 POJO,跟 ThreadLocal 没关系了。用"快照对象传递"替代"上下文传递",规避了跨线程丢失。这个手法比换 TTL 更轻量,值得记下来。


七、可带走的 6 条诀窍

  1. 切点越宽,越要把便宜的判断放前面。 拦截所有 Controller 很方便,但"要不要记"的判断必须在构造对象、查配置之前完成。

  2. 旁路组件的线程池不能太小,也不能用 CallerRunsPolicy。 队列满了让业务线程自己写日志,等于把旁路变成主链路。日志可以丢,接口不能卡。

  3. 链路共用线程池要考虑峰值叠加。 登录日志的峰值(早上打卡)和操作日志的峰值(下午业务高峰)不是同一时刻,共用时至少要按叠加后的量估。

  4. "成功"事件监听不到"失败"。 Sa-Token 的 doLogin 只在成功时回调,Spring 的很多事件监听器同理。想要失败审计,得在失败分支里手动补。

  5. traceId 要进数据库,不能只进 MDC。 只进 MDC 意味着日志表和文件日志是两个世界,排查时只能靠时间范围猜。

  6. 开关要覆盖所有分支。 6 个事件方法里 4 个漏了开关检查,这种不一致不会报错,但会在某一天让人困惑半小时。


八、那个没说完的事

写这篇的时候,我顺着"登录失败"这条线往下追,发现了另一个更麻烦的问题。

UsernamePasswordAuthStrategy 里是这么写的:

java 复制代码
@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) {
        recordLoginFailure(null, "用户名或密码错误");
    }
    return loginUser;
}

看起来没问题对吧?但去翻 authenticateByUsernamePassword 的实现,密码错误时它是抛异常的:

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

也就是说,if (loginUser == null) 这个分支永远进不去 。而 recordLoginFailure 里第一行就是:

java 复制代码
if (!isLoginLockEnabled() || loginUser == null) {
    throw new RuntimeException(errorMessage);      // ← 就算进去了,传 null 也会直接抛出,不计数
}

两道保险叠加的结果:密码错误时,失败次数不会被记录,账号永远不会被锁定。

暴力破解防护形同虚设。

这个问题我已经反馈给作者了,修复思路是让认证方法在失败时返回可区分的结果(比如 Optional.empty() 或者带错误码的包装对象),而不是抛异常------失败路径用异常来表达,就等于把"计数""锁定""审计"这些后续动作全给跳过了

完整的登录链路拆解我下一篇单独写,包括认证策略链、账号锁定的正确实现、多租户下的用户匹配。


这篇要是帮你看清了日志组件的坑,点个赞吧 🙏

我赌你们项目的登录日志表里也是 0 条失败记录------评论区可以现在去查一下,SELECT COUNT(*) FROM sys_login_log WHERE login_status = 0,看看是不是 0。


项目地址

系列回顾

  1. 数据权限拦截器:681 行是怎么改写 SQL 的
  2. 多租户 tenant Starter:数据源级 + 行级双隔离
  3. 多租户 × 数据权限共存:拦截器注册顺序的 4 个坑
  4. 幂等 Starter:1279 行里的 5 个隐蔽坑
  5. 操作日志 Starter:1383 行里的 5 个反直觉设计(本文)

标签#Java #Spring Boot #日志 #Sa-Token #源码拆解 #架构设计 #性能优化

相关推荐
m0_587383001 小时前
本地同城无人机作业接单系统源码:架构拆解与抢单调度实现
java·小程序·架构·需求分析
FantanLee1 小时前
面试级「分布式 ID 设计方案」
java
青春易逝丶1 小时前
Maven
java·maven
高级程序源1 小时前
django校企合作实习基地管理系统82506-计算机课程设计、毕业设计
vue.js·后端·python·mysql·django·课程设计·pygame
吠品1 小时前
纯HTML+ECharts构建交互式数据看板:实现思路与踩坑记录
java·服务器·数据库
盖伦发发1 小时前
Redis 核心教学: 数据结构, 缓存设计, 分布式锁
java·redis·后端·软件工程
卷毛的技术笔记1 小时前
RocketMQ事务消息:我把分布式事务这层窗户纸捅破了
java·分布式·后端·java-rocketmq
.冰块.2 小时前
OpenStack 为什么被叫做云操作系统?核心架构深度解析
架构·open stack
Brilliantwxx2 小时前
【STM32】 从HAL库源码深度解析I2C(源码解析+面试题)
开发语言·stm32·单片机·嵌入式硬件·架构