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:
29
par-api/src/main/java/com/par/api/config/SecurityCheck.java
Normal file
29
par-api/src/main/java/com/par/api/config/SecurityCheck.java
Normal 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("============================================");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 管理员控制器
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user