refactor: 彻底移除 apiSecret 认证方式,仅保留 Bearer + PSK

删除:
- HmacUtil: deriveApiKey/deriveApiSecret/verifyMediabotPsk
- HmacAuthInterceptor: apiSecret 验签/自动创建账户/readBody
- AccountService: generateApiCredentials/registerByInterceptor
- AccountDTO: apiKey 字段
- AuthController: register 返回 apiSecret
- AdminController: generateApiKey 端点
- Users.vue: API Key 列/生成密钥弹窗
- api/index.js: genApiKey

认证方式简化为两种:
  Bearer Token → Web 管理后台
  PSK 签名    → 外部集成客户端
This commit is contained in:
mediabot-pt
2026-06-29 16:56:00 +08:00
parent 5304449de6
commit 311ead0e89
10 changed files with 56 additions and 315 deletions

View File

@@ -7,7 +7,7 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 账户信息 DTO(响应用,不包含敏感字段)
* 账户信息 DTO
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@@ -22,7 +22,4 @@ public class AccountDTO {
/** 登录 token(仅登录接口返回) */
private String token;
/** API Key(管理员可见,用于外部集成) */
private String apiKey;
}

View File

@@ -6,7 +6,6 @@ import com.par.core.service.AccountService;
import com.par.core.service.PskService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -18,14 +17,11 @@ import java.util.Base64;
/**
* API 鉴权拦截器
* <p>
* 支持两种认证方式(按优先级):
* 1. Bearer Token — Web 管理后台使用,登录后由服务端签发
* 2. HMAC 请求签名 — 外部 API 客户端使用,以 X-Signature/X-Timestamp/X-Email 头携带
* 1. Bearer Token — Web 管理后台
* 2. PSK 签名 — 外部集成客户端
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HmacAuthInterceptor implements HandlerInterceptor {
private final AccountService accountService;
@@ -37,20 +33,20 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
private static final String HEADER_SIGNATURE = "X-Signature";
private static final String HEADER_TIMESTAMP = "X-Timestamp";
private static final String HEADER_EMAIL = "X-Email";
private static final String HEADER_AUTHORIZATION = "Authorization";
/** Token 有效期(小时) */
private static final long TOKEN_EXPIRY_HOURS = 24;
/** HMAC 时间窗口(秒) */
private static final long HMAC_WINDOW_SECONDS = 60;
public HmacAuthInterceptor(AccountService accountService, PskService pskService) {
this.accountService = accountService;
this.pskService = pskService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// ======== 方式 1:Bearer Token 认证(Web 管理后台) ========
String authHeader = request.getHeader(HEADER_AUTHORIZATION);
// 方式 1:Bearer Token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
String email = verifyBearerToken(token);
String email = verifyBearerToken(authHeader.substring(7));
if (email != null) {
Account account = accountService.findByEmail(email);
if (account != null && Boolean.TRUE.equals(account.getIsActive())) {
@@ -63,18 +59,15 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
return false;
}
// ======== 方式 2:PSK 签名认证(mediabot 等集成客户端) ========
String pskSig = request.getHeader(HEADER_SIGNATURE);
// 方式 2:PSK 签名
String sig = request.getHeader(HEADER_SIGNATURE);
String ts = request.getHeader(HEADER_TIMESTAMP);
String email = request.getHeader(HEADER_EMAIL);
if (pskSig != null && ts != null && email != null) {
if (sig != null && ts != null && email != null) {
try {
long timestamp = Long.parseLong(ts);
String pskVersion = pskService.verify(email, timestamp, pskSig);
if (pskVersion != null) {
if (pskService.verify(email, Long.parseLong(ts), sig) != null) {
request.setAttribute("accountId", 0L);
request.setAttribute("trustLevel", com.par.core.enums.TrustLevel.TRUSTED);
request.setAttribute("pskVersion", pskVersion);
return true;
}
} catch (NumberFormatException ignored) {}
@@ -82,72 +75,33 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
return false;
}
// ======== 方式 3:无有效认证头 → 拒绝 ========
writeError(response, 401, "Missing or invalid authentication");
return false;
}
private String readBody(HttpServletRequest request) throws IOException {
if (request.getContentLength() <= 0) {
return "";
}
try (var reader = request.getReader()) {
return reader.lines().collect(Collectors.joining("\n"));
}
}
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(String.format("{\"code\":%d,\"message\":\"%s\",\"data\":null}", status, message));
}
/**
* 生成登录 token(供 AuthController 调用)
* 格式: Base64(email:expiryTimestamp:HmacSHA256(email:expiry, hmacSecret))
*/
public String generateToken(String email) {
long expiry = System.currentTimeMillis() / 1000 + TOKEN_EXPIRY_HOURS * 3600;
String payload = email + ":" + expiry;
String sig = HmacUtil.sign(hmacSecret, payload);
String token = payload + ":" + sig;
return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString((payload + ":" + sig).getBytes(StandardCharsets.UTF_8));
}
/**
* 自动创建账户(DB 重置后首次 HMAC 请求触发恢复)
*/
private Account autoCreateAccount(String email) {
var account = new Account();
account.setEmail(email.toLowerCase().trim());
account.setApiKey(HmacUtil.deriveApiKey(email, hmacSecret));
account.setApiSecret(HmacUtil.deriveApiSecret(email, hmacSecret));
account.setTrustLevel(com.par.core.enums.TrustLevel.MEMBER);
account.setIsActive(true);
accountService.registerByInterceptor(account);
log.info("Auto-created account via HMAC: email={}", email);
return account;
}
/**
* 校验 Bearer token,返回 email(失败返回 null)
*/
private String verifyBearerToken(String token) {
try {
String decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
String[] parts = decoded.split(":", 3);
if (parts.length != 3) return null;
String email = parts[0];
long expiry = Long.parseLong(parts[1]);
String sig = parts[2];
if (System.currentTimeMillis() / 1000 > expiry) return null;
String payload = email + ":" + expiry;
if (HmacUtil.verify(hmacSecret, payload, sig)) {
return email;
}
if (HmacUtil.verify(hmacSecret, payload, parts[2])) return email;
} catch (Exception e) {
log.debug("Bearer token validation failed: {}", e.getMessage());
}

View File

@@ -3,79 +3,32 @@ package com.par.core.service;
import com.par.core.dto.AccountDTO;
import com.par.core.dto.LoginRequest;
import com.par.core.dto.RegisterRequest;
import java.util.List;
import com.par.core.entity.Account;
import java.util.List;
/**
* 账户服务接口
*/
public interface AccountService {
/**
* 用户注册
*
* @param request 注册请求
* @return 注册成功的账户
*/
Account register(RegisterRequest request);
/**
* 用户登录(验证密码)
*
* @param request 登录请求
* @return 登录成功的账户
*/
Account login(LoginRequest request);
/**
* 根据邮箱查找账户
*/
Account findByEmail(String email);
/**
* 根据ID查找账户
*/
Account findById(Long id);
/**
* 拦截器直接创建账户(HMAC 自动恢复场景)
*/
void registerByInterceptor(Account account);
/**
* 更新账户
*/
void updateAccount(Account account);
/**
* 转换为 DTO(脱敏)
*/
AccountDTO toDTO(Account account);
/**
* 根据任意绑定邮箱查找账户
*/
Account findByAnyEmail(String email);
/**
* 生成/重置 API Key 密钥对
* @return [apiKey, apiSecret]
*/
String[] generateApiCredentials(Long accountId);
void updateAccount(Account account);
AccountDTO toDTO(Account account);
/**
* 绑定额外邮箱
*/
void bindEmail(Long accountId, String email);
/**
* 解绑邮箱
*/
void unbindEmail(Long emailId);
/**
* 获取账户的所有绑定邮箱
*/
List<String> listEmails(Long accountId);
}

View File

@@ -84,20 +84,29 @@ public class PskService {
/**
* 使用所有活跃 PSK 逐一验证签名
* signContent = email + ":" + timestamp
* @return 匹配的 PSK 版本号,null 表示验证失败
*/
public String verify(String email, long timestamp, String signature) {
for (Psk psk : pskMapper.selectActive()) {
if (HmacUtil.verifyMediabotPsk(email, timestamp, signature, psk.getSecret())) {
return psk.getVersion();
}
}
// fallback: 也试 env PSK(DB 丢失时仍可用)
if (envPsk != null && !envPsk.isBlank()) {
if (HmacUtil.verifyMediabotPsk(email, timestamp, signature, envPsk)) {
return "env";
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) > 300) return null; // 5 分钟窗口
String payload = email.toLowerCase().trim() + ":" + timestamp;
String[] secrets = getActiveSecrets();
for (String secret : secrets) {
if (HmacUtil.verify(secret, payload, signature)) {
return "ok";
}
}
return null;
}
private String[] getActiveSecrets() {
List<String> secrets = pskMapper.selectActive().stream()
.map(Psk::getSecret).collect(java.util.stream.Collectors.toList());
if (envPsk != null && !envPsk.isBlank()) {
secrets.add(envPsk);
}
return secrets.toArray(new String[0]);
}
}

View File

@@ -15,7 +15,6 @@ import com.par.core.mapper.AnonymousStatMapper;
import com.par.core.service.AccountService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -37,15 +36,11 @@ public class AccountServiceImpl implements AccountService {
private final AnonymousStatMapper anonymousStatMapper;
private final AccountEmailMapper accountEmailMapper;
@Value("${hmac.secret}")
private String hmacSecret;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
@Override
@Transactional
public Account register(RegisterRequest request) {
// 检查邮箱是否已注册
Account existing = accountMapper.selectByEmail(request.getEmail());
if (existing != null) {
throw new IllegalArgumentException("该邮箱已被注册");
@@ -54,7 +49,6 @@ public class AccountServiceImpl implements AccountService {
Account account = new Account();
account.setEmail(request.getEmail().toLowerCase().trim());
// 密码为空则自动生成(机器注册场景)
String password = request.getPassword();
if (password == null || password.isBlank()) {
byte[] pwdBytes = new byte[16];
@@ -66,13 +60,8 @@ public class AccountServiceImpl implements AccountService {
account.setTrustLevel(TrustLevel.MEMBER);
account.setIsActive(true);
// 确定性推导 API Key(同 email + 同 HMAC_SECRET → 永远相同)
account.setApiKey(HmacUtil.deriveApiKey(account.getEmail(), hmacSecret));
account.setApiSecret(HmacUtil.deriveApiSecret(account.getEmail(), hmacSecret));
accountMapper.insert(account);
// 关联匿名统计记录
if (request.getAnonymousId() != null && !request.getAnonymousId().isBlank()) {
associateAnonymousStats(request.getAnonymousId(), account.getId(), request.getEmail());
}
@@ -107,14 +96,13 @@ public class AccountServiceImpl implements AccountService {
}
@Override
@Transactional
public void registerByInterceptor(Account account) {
// 自动生成随机密码
byte[] pwdBytes = new byte[16];
SECURE_RANDOM.nextBytes(pwdBytes);
account.setPasswordHash(PasswordUtil.encode(
Base64.getUrlEncoder().withoutPadding().encodeToString(pwdBytes)));
accountMapper.insert(account);
public Account findByAnyEmail(String email) {
email = email.toLowerCase().trim();
Account account = accountMapper.selectByEmail(email);
if (account != null) return account;
AccountEmail ae = accountEmailMapper.selectByEmail(email);
if (ae != null) return accountMapper.selectById(ae.getAccountId());
return null;
}
@Override
@@ -132,43 +120,9 @@ public class AccountServiceImpl implements AccountService {
dto.setTrustLevel(account.getTrustLevel());
dto.setIsActive(account.getIsActive());
dto.setCreatedAt(account.getCreatedAt());
dto.setApiKey(account.getApiKey());
return dto;
}
@Override
public Account findByAnyEmail(String email) {
email = email.toLowerCase().trim();
// 先查主邮箱
Account account = accountMapper.selectByEmail(email);
if (account != null) return account;
// 再查关联邮箱
AccountEmail ae = accountEmailMapper.selectByEmail(email);
if (ae != null) return accountMapper.selectById(ae.getAccountId());
return null;
}
@Override
@Transactional
public String[] generateApiCredentials(Long accountId) {
Account account = accountMapper.selectById(accountId);
if (account == null) throw new IllegalArgumentException("Account not found");
byte[] keyBytes = new byte[12];
SECURE_RANDOM.nextBytes(keyBytes);
String apiKey = "par_" + bytesToHex(keyBytes);
byte[] secretBytes = new byte[32];
SECURE_RANDOM.nextBytes(secretBytes);
String apiSecret = Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes);
account.setApiKey(apiKey);
account.setApiSecret(apiSecret);
accountMapper.updateById(account);
return new String[]{apiKey, apiSecret};
}
@Override
@Transactional
public void bindEmail(Long accountId, String email) {
@@ -189,8 +143,6 @@ public class AccountServiceImpl implements AccountService {
public void unbindEmail(Long emailId) {
AccountEmail ae = accountEmailMapper.selectById(emailId);
if (ae == null) throw new IllegalArgumentException("Email binding not found");
// 检查是否是唯一邮箱:主邮箱 + 至少保留一个关联邮箱
Account account = accountMapper.selectById(ae.getAccountId());
long emailCount = accountEmailMapper.selectByAccountId(ae.getAccountId()).size();
if (account.getEmail().equals(ae.getEmail()) && emailCount <= 1) {
@@ -205,24 +157,13 @@ public class AccountServiceImpl implements AccountService {
if (account == null) return Collections.emptyList();
List<String> emails = accountEmailMapper.selectByAccountId(accountId)
.stream().map(AccountEmail::getEmail).collect(Collectors.toList());
// 确保主邮箱在列表首位
if (!emails.contains(account.getEmail())) {
emails.add(0, account.getEmail());
}
return emails;
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
/**
* 注册时关联匿名统计记录
*/
private void associateAnonymousStats(String anonymousId, Long accountId, String email) {
// 先按 anonymous_id 查找
AnonymousStat stat = anonymousStatMapper.selectByAnonymousId(anonymousId);
if (stat != null && stat.getAssociatedAccountId() == null) {
stat.setAssociatedAccountId(accountId);
@@ -230,8 +171,6 @@ public class AccountServiceImpl implements AccountService {
log.info("Associated anonymous stat {} with account {}", anonymousId, accountId);
return;
}
// 再按邮箱哈希查找(可能匿名ID不匹配但邮箱相同)
String emailHash = HmacUtil.sha256(email.toLowerCase().trim());
AnonymousStat byHash = anonymousStatMapper.selectByRawEmailHash(emailHash);
if (byHash != null && byHash.getAssociatedAccountId() == null) {