diff --git a/par-api/src/main/java/com/par/api/config/SecurityCheck.java b/par-api/src/main/java/com/par/api/config/SecurityCheck.java new file mode 100644 index 0000000..6c2c0f1 --- /dev/null +++ b/par-api/src/main/java/com/par/api/config/SecurityCheck.java @@ -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("============================================"); + } + } +} diff --git a/par-api/src/main/java/com/par/api/controller/AdminController.java b/par-api/src/main/java/com/par/api/controller/AdminController.java index 4c00d1e..99d9601 100644 --- a/par-api/src/main/java/com/par/api/controller/AdminController.java +++ b/par-api/src/main/java/com/par/api/controller/AdminController.java @@ -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; /** * 管理员控制器 diff --git a/par-core/src/main/java/com/par/core/service/PskService.java b/par-core/src/main/java/com/par/core/service/PskService.java index 0d27518..af741ea 100644 --- a/par-core/src/main/java/com/par/core/service/PskService.java +++ b/par-core/src/main/java/com/par/core/service/PskService.java @@ -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()) { diff --git a/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java index d3e87ae..d344604 100644 --- a/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java @@ -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 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());