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:
@@ -71,6 +71,24 @@ public class HmacUtil {
|
||||
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) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
|
||||
@@ -89,22 +89,12 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
// 查找账户(支持主邮箱和关联邮箱)
|
||||
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
|
||||
String body = readBody(request);
|
||||
String bodyHash = HmacUtil.sha256(body);
|
||||
|
||||
// 构建签名内容并验证
|
||||
// 构建签名内容
|
||||
String signContent = HmacUtil.buildSignContent(
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
@@ -112,8 +102,22 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||
bodyHash
|
||||
);
|
||||
|
||||
// 使用 api_secret 作为 HMAC 密钥
|
||||
boolean valid = HmacUtil.verify(account.getApiSecret(), signContent, signature);
|
||||
boolean valid;
|
||||
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) {
|
||||
writeError(response, 401, "Invalid signature");
|
||||
return false;
|
||||
@@ -153,6 +157,21 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -38,6 +38,11 @@ public interface AccountService {
|
||||
*/
|
||||
Account findById(Long id);
|
||||
|
||||
/**
|
||||
* 拦截器直接创建账户(HMAC 自动恢复场景)
|
||||
*/
|
||||
void registerByInterceptor(Account account);
|
||||
|
||||
/**
|
||||
* 转换为 DTO(脱敏)
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
|
||||
@@ -36,6 +37,9 @@ 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
|
||||
@@ -62,14 +66,9 @@ public class AccountServiceImpl implements AccountService {
|
||||
account.setTrustLevel(TrustLevel.MEMBER);
|
||||
account.setIsActive(true);
|
||||
|
||||
// 自动生成 API Key(静默认证凭据)
|
||||
byte[] keyBytes = new byte[12];
|
||||
SECURE_RANDOM.nextBytes(keyBytes);
|
||||
account.setApiKey("par_" + bytesToHex(keyBytes));
|
||||
|
||||
byte[] secretBytes = new byte[32];
|
||||
SECURE_RANDOM.nextBytes(secretBytes);
|
||||
account.setApiSecret(Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes));
|
||||
// 确定性推导 API Key(同 email + 同 HMAC_SECRET → 永远相同)
|
||||
account.setApiKey(HmacUtil.deriveApiKey(account.getEmail(), hmacSecret));
|
||||
account.setApiSecret(HmacUtil.deriveApiSecret(account.getEmail(), hmacSecret));
|
||||
|
||||
accountMapper.insert(account);
|
||||
|
||||
@@ -107,6 +106,17 @@ public class AccountServiceImpl implements AccountService {
|
||||
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
|
||||
public AccountDTO toDTO(Account account) {
|
||||
if (account == null) return null;
|
||||
|
||||
Reference in New Issue
Block a user