feat: 确定性密钥推导 + HMAC 自动恢复账户

- HmacUtil.deriveApiSecret/deriveApiKey: email+HMAC_SECRET 确定性推导
- 注册时用推导值替代随机生成(同 email 永远同 key)
- HMAC 拦截器:未知邮箱但签名匹配 → 自动创建账户
- 重装后 mediabot 直接发 HMAC 请求即可恢复,无需重新注册

核心: apiSecret = HMAC(email + ':par-api-secret', HMAC_SECRET)
      同一 email + 同一 HMAC_SECRET → 永久不变的凭据
This commit is contained in:
mediabot-pt
2026-06-29 16:38:37 +08:00
parent 92f4ed7a2a
commit aed8035995
4 changed files with 73 additions and 21 deletions

View File

@@ -71,6 +71,24 @@ public class HmacUtil {
return hash.substring(0, 16); return hash.substring(0, 16);
} }
/**
* 从 email + 服务端密钥确定性推导 api_secret
* 同一 email + 同一 HMAC_SECRET → 永远返回相同值
*/
public static String deriveApiSecret(String email, String hmacSecret) {
String input = email.toLowerCase().trim() + ":par-api-secret";
return sign(hmacSecret, input);
}
/**
* 从 email + 服务端密钥确定性推导 api_key
*/
public static String deriveApiKey(String email, String hmacSecret) {
String input = email.toLowerCase().trim() + ":par-api-key";
String hash = sign(hmacSecret, input);
return "par_" + sha256(hash).substring(0, 16);
}
private static String bytesToHex(byte[] bytes) { private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (byte b : bytes) { for (byte b : bytes) {

View File

@@ -89,22 +89,12 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
// 查找账户(支持主邮箱和关联邮箱) // 查找账户(支持主邮箱和关联邮箱)
Account account = accountService.findByAnyEmail(email); Account account = accountService.findByAnyEmail(email);
if (account == null || !Boolean.TRUE.equals(account.getIsActive())) {
writeError(response, 401, "Invalid credentials");
return false;
}
// 检查是否有 API Secret
if (account.getApiSecret() == null || account.getApiSecret().isBlank()) {
writeError(response, 401, "API credentials not configured for this account");
return false;
}
// 计算 body hash // 计算 body hash
String body = readBody(request); String body = readBody(request);
String bodyHash = HmacUtil.sha256(body); String bodyHash = HmacUtil.sha256(body);
// 构建签名内容并验证 // 构建签名内容
String signContent = HmacUtil.buildSignContent( String signContent = HmacUtil.buildSignContent(
request.getMethod(), request.getMethod(),
request.getRequestURI(), request.getRequestURI(),
@@ -112,8 +102,22 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
bodyHash bodyHash
); );
// 使用 api_secret 作为 HMAC 密钥 boolean valid;
boolean valid = HmacUtil.verify(account.getApiSecret(), signContent, signature); if (account != null && Boolean.TRUE.equals(account.getIsActive())) {
// 已有账户:用 api_secret 验签
if (account.getApiSecret() == null || account.getApiSecret().isBlank()) {
writeError(response, 401, "API credentials not configured");
return false;
}
valid = HmacUtil.verify(account.getApiSecret(), signContent, signature);
} else {
// 未知邮箱:尝试用确定性推导的 secret 验签,匹配则自动创建账户
String derivedSecret = HmacUtil.deriveApiSecret(email, hmacSecret);
valid = HmacUtil.verify(derivedSecret, signContent, signature);
if (valid) {
account = autoCreateAccount(email);
}
}
if (!valid) { if (!valid) {
writeError(response, 401, "Invalid signature"); writeError(response, 401, "Invalid signature");
return false; return false;
@@ -153,6 +157,21 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(token.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) * 校验 Bearer token,返回 email(失败返回 null)
*/ */

View File

@@ -38,6 +38,11 @@ public interface AccountService {
*/ */
Account findById(Long id); Account findById(Long id);
/**
* 拦截器直接创建账户(HMAC 自动恢复场景)
*/
void registerByInterceptor(Account account);
/** /**
* 转换为 DTO(脱敏) * 转换为 DTO(脱敏)
*/ */

View File

@@ -15,6 +15,7 @@ import com.par.core.mapper.AnonymousStatMapper;
import com.par.core.service.AccountService; import com.par.core.service.AccountService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -36,6 +37,9 @@ public class AccountServiceImpl implements AccountService {
private final AnonymousStatMapper anonymousStatMapper; private final AnonymousStatMapper anonymousStatMapper;
private final AccountEmailMapper accountEmailMapper; private final AccountEmailMapper accountEmailMapper;
@Value("${hmac.secret}")
private String hmacSecret;
private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final SecureRandom SECURE_RANDOM = new SecureRandom();
@Override @Override
@@ -62,14 +66,9 @@ public class AccountServiceImpl implements AccountService {
account.setTrustLevel(TrustLevel.MEMBER); account.setTrustLevel(TrustLevel.MEMBER);
account.setIsActive(true); account.setIsActive(true);
// 自动生成 API Key(静默认证凭据) // 确定性推导 API Key(同 email + 同 HMAC_SECRET → 永远相同)
byte[] keyBytes = new byte[12]; account.setApiKey(HmacUtil.deriveApiKey(account.getEmail(), hmacSecret));
SECURE_RANDOM.nextBytes(keyBytes); account.setApiSecret(HmacUtil.deriveApiSecret(account.getEmail(), hmacSecret));
account.setApiKey("par_" + bytesToHex(keyBytes));
byte[] secretBytes = new byte[32];
SECURE_RANDOM.nextBytes(secretBytes);
account.setApiSecret(Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes));
accountMapper.insert(account); accountMapper.insert(account);
@@ -107,6 +106,17 @@ public class AccountServiceImpl implements AccountService {
return accountMapper.selectById(id); return accountMapper.selectById(id);
} }
@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);
}
@Override @Override
public AccountDTO toDTO(Account account) { public AccountDTO toDTO(Account account) {
if (account == null) return null; if (account == null) return null;