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.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理员控制器
|
* 管理员控制器
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public class PskService {
|
|||||||
@Value("${par.mediabot.psk:}")
|
@Value("${par.mediabot.psk:}")
|
||||||
private String envPsk;
|
private String envPsk;
|
||||||
|
|
||||||
|
@Value("${par.auth.hmac-window-seconds:60}")
|
||||||
|
private int pskWindowSeconds;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动时:如果配置了环境变量 PSK 且 DB 中不存在,自动导入为 v1
|
* 启动时:如果配置了环境变量 PSK 且 DB 中不存在,自动导入为 v1
|
||||||
*/
|
*/
|
||||||
@@ -89,7 +92,7 @@ public class PskService {
|
|||||||
*/
|
*/
|
||||||
public String verify(String method, String path, long timestamp, String signature) {
|
public String verify(String method, String path, long timestamp, String signature) {
|
||||||
long now = System.currentTimeMillis() / 1000;
|
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;
|
String payload = method.toUpperCase() + ":" + path + ":" + timestamp;
|
||||||
|
|
||||||
for (String secret : getActiveSecrets()) {
|
for (String secret : getActiveSecrets()) {
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,8 +43,18 @@ public class AccountServiceImpl implements AccountService {
|
|||||||
private final AnonymousStatMapper anonymousStatMapper;
|
private final AnonymousStatMapper anonymousStatMapper;
|
||||||
private final AccountEmailMapper accountEmailMapper;
|
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();
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
/** 登录失败计数: email → (失败次数, 锁定到期时间) */
|
||||||
|
private final Cache<String, LoginFailRecord> loginFails = Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(Duration.ofHours(24))
|
||||||
|
.build();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public Account register(RegisterRequest request) {
|
public Account register(RegisterRequest request) {
|
||||||
@@ -72,19 +89,47 @@ public class AccountServiceImpl implements AccountService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Account login(LoginRequest request) {
|
public Account login(LoginRequest request) {
|
||||||
Account account = accountMapper.selectByEmail(request.getEmail().toLowerCase().trim());
|
String email = request.getEmail().toLowerCase().trim();
|
||||||
if (account == null) {
|
|
||||||
|
// 检查是否被锁定
|
||||||
|
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("邮箱或密码错误");
|
throw new IllegalArgumentException("邮箱或密码错误");
|
||||||
}
|
}
|
||||||
if (!Boolean.TRUE.equals(account.getIsActive())) {
|
if (!Boolean.TRUE.equals(account.getIsActive())) {
|
||||||
throw new IllegalArgumentException("账户已被禁用");
|
throw new IllegalArgumentException("账户已被禁用");
|
||||||
}
|
}
|
||||||
if (!PasswordUtil.matches(request.getPassword(), account.getPasswordHash())) {
|
|
||||||
throw new IllegalArgumentException("邮箱或密码错误");
|
// 登录成功,清除失败记录
|
||||||
}
|
loginFails.invalidate(email);
|
||||||
return account;
|
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
|
@Override
|
||||||
public Account findByEmail(String email) {
|
public Account findByEmail(String email) {
|
||||||
return accountMapper.selectByEmail(email.toLowerCase().trim());
|
return accountMapper.selectByEmail(email.toLowerCase().trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user