fix: 安全加固 — HMAC_SECRET 启动检查 + 登录锁定 + PSK 窗口配置

- SecurityCheck: 启动时 HMAC_SECRET 为空输出 ERROR 日志
- AccountServiceImpl: Caffeine 缓存实现登录失败计数/锁定
  (par.security.login-max-failures=5, login-lock-minutes=30)
- PskService: pskWindowSeconds 从 application.yml 读取
- AdminController: 清理重复 import
This commit is contained in:
mediabot-pt
2026-06-29 17:05:43 +08:00
parent c9dc78b6ef
commit bb96062b7c
4 changed files with 83 additions and 8 deletions

View File

@@ -0,0 +1,29 @@
package com.par.api.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* 启动时安全配置检查
*/
@Slf4j
@Component
public class SecurityCheck {
@Value("${hmac.secret:}")
private String hmacSecret;
@EventListener(ApplicationReadyEvent.class)
public void checkHmacSecret() {
if (hmacSecret == null || hmacSecret.isBlank()) {
log.error("============================================");
log.error(" HMAC_SECRET is not set!");
log.error(" Set HMAC_SECRET environment variable.");
log.error(" Without it, Bearer tokens are forgeable.");
log.error("============================================");
}
}
}

View File

@@ -21,8 +21,6 @@ import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Map;
import java.util.List;
/**
* 管理员控制器

View File

@@ -25,6 +25,9 @@ public class PskService {
@Value("${par.mediabot.psk:}")
private String envPsk;
@Value("${par.auth.hmac-window-seconds:60}")
private int pskWindowSeconds;
/**
* 启动时:如果配置了环境变量 PSK 且 DB 中不存在,自动导入为 v1
*/
@@ -89,7 +92,7 @@ public class PskService {
*/
public String verify(String method, String path, long timestamp, String signature) {
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) > 300) return null; // 5 分钟窗口
if (Math.abs(now - timestamp) > pskWindowSeconds) return null;
String payload = method.toUpperCase() + ":" + path + ":" + timestamp;
for (String secret : getActiveSecrets()) {

View File

@@ -18,10 +18,17 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Value;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
@@ -36,8 +43,18 @@ public class AccountServiceImpl implements AccountService {
private final AnonymousStatMapper anonymousStatMapper;
private final AccountEmailMapper accountEmailMapper;
@Value("${par.security.login-max-failures:5}")
private int maxFailures;
@Value("${par.security.login-lock-minutes:30}")
private int lockMinutes;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/** 登录失败计数: email → (失败次数, 锁定到期时间) */
private final Cache<String, LoginFailRecord> loginFails = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofHours(24))
.build();
@Override
@Transactional
public Account register(RegisterRequest request) {
@@ -72,19 +89,47 @@ public class AccountServiceImpl implements AccountService {
@Override
public Account login(LoginRequest request) {
Account account = accountMapper.selectByEmail(request.getEmail().toLowerCase().trim());
if (account == null) {
String email = request.getEmail().toLowerCase().trim();
// 检查是否被锁定
LoginFailRecord record = loginFails.getIfPresent(email);
if (record != null && record.isLocked()) {
long remain = (record.lockedUntil.toEpochMilli() - System.currentTimeMillis()) / 1000 / 60;
throw new IllegalArgumentException("账户临时锁定,请 " + remain + " 分钟后重试");
}
Account account = accountMapper.selectByEmail(email);
if (account == null || !PasswordUtil.matches(request.getPassword(), account.getPasswordHash())) {
// 记录失败
if (record == null) record = new LoginFailRecord();
record.increment(lockMinutes);
loginFails.put(email, record);
throw new IllegalArgumentException("邮箱或密码错误");
}
if (!Boolean.TRUE.equals(account.getIsActive())) {
throw new IllegalArgumentException("账户已被禁用");
}
if (!PasswordUtil.matches(request.getPassword(), account.getPasswordHash())) {
throw new IllegalArgumentException("邮箱或密码错误");
}
// 登录成功,清除失败记录
loginFails.invalidate(email);
return account;
}
private static class LoginFailRecord {
AtomicInteger count = new AtomicInteger(0);
Instant lockedUntil = Instant.EPOCH;
void increment(int lockMinutes) {
if (count.incrementAndGet() >= 5) {
lockedUntil = Instant.now().plusSeconds(lockMinutes * 60L);
}
}
boolean isLocked() {
return Instant.now().isBefore(lockedUntil);
}
}
@Override
public Account findByEmail(String email) {
return accountMapper.selectByEmail(email.toLowerCase().trim());