feat: API Key 静默认证 + 多邮箱支持

核心改动:
- V3 迁移: accounts 加 api_key/api_secret + account_emails 多邮箱关联表
- HMAC 验签改用 api_secret 替代密码哈希(改密码不影响集成)
- findByAnyEmail 支持主邮箱 + 关联邮箱查找
- AdminController: API Key 生成/重置 + 邮箱绑定/解绑/列表
- 前端 Users.vue: API Key 列 + 生成按钮 + Secret 复制弹窗
- 现有邮箱自动迁移到 account_emails 表

认证流程:
  旧: X-Email → account.email → passwordHash 作 HMAC key
  新: X-Email → account_emails ∪ account.email → api_secret 作 HMAC key
This commit is contained in:
mediabot-pt
2026-06-29 16:30:53 +08:00
parent c4877f983a
commit 2d91552ea4
11 changed files with 349 additions and 13 deletions

View File

@@ -22,4 +22,7 @@ public class AccountDTO {
/** 登录 token(仅登录接口返回) */
private String token;
/** API Key(管理员可见,用于外部集成) */
private String apiKey;
}

View File

@@ -22,6 +22,12 @@ public class Account {
@TableField("password_hash")
private String passwordHash;
@TableField("api_key")
private String apiKey;
@TableField("api_secret")
private String apiSecret;
@TableField("display_name")
private String displayName;

View File

@@ -0,0 +1,29 @@
package com.par.core.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 账户多邮箱关联实体
*/
@Data
@TableName("account_emails")
public class AccountEmail {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("account_id")
private Long accountId;
@TableField("email")
private String email;
@TableField("verified")
private Boolean verified;
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}

View File

@@ -87,13 +87,19 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
return false;
}
// 查找账户
Account account = accountService.findByEmail(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
String body = readBody(request);
String bodyHash = HmacUtil.sha256(body);
@@ -106,8 +112,8 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
bodyHash
);
// 使用密码哈希作为 HMAC 密钥
boolean valid = HmacUtil.verify(account.getPasswordHash(), signContent, signature);
// 使用 api_secret 作为 HMAC 密钥
boolean valid = HmacUtil.verify(account.getApiSecret(), signContent, signature);
if (!valid) {
writeError(response, 401, "Invalid signature");
return false;

View File

@@ -0,0 +1,22 @@
package com.par.core.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.par.core.entity.AccountEmail;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 多邮箱关联 Mapper
*/
@Mapper
public interface AccountEmailMapper extends BaseMapper<AccountEmail> {
@Select("SELECT * FROM account_emails WHERE email = #{email} AND verified = 1 LIMIT 1")
AccountEmail selectByEmail(@Param("email") String email);
@Select("SELECT * FROM account_emails WHERE account_id = #{accountId}")
List<AccountEmail> selectByAccountId(@Param("accountId") Long accountId);
}

View File

@@ -3,6 +3,8 @@ 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;
/**
@@ -40,4 +42,30 @@ public interface AccountService {
* 转换为 DTO(脱敏)
*/
AccountDTO toDTO(Account account);
/**
* 根据任意绑定邮箱查找账户
*/
Account findByAnyEmail(String email);
/**
* 生成/重置 API Key 密钥对
* @return [apiKey, apiSecret]
*/
String[] generateApiCredentials(Long accountId);
/**
* 绑定额外邮箱
*/
void bindEmail(Long accountId, String email);
/**
* 解绑邮箱
*/
void unbindEmail(Long emailId);
/**
* 获取账户的所有绑定邮箱
*/
List<String> listEmails(Long accountId);
}

View File

@@ -6,8 +6,10 @@ import com.par.core.dto.AccountDTO;
import com.par.core.dto.LoginRequest;
import com.par.core.dto.RegisterRequest;
import com.par.core.entity.Account;
import com.par.core.entity.AccountEmail;
import com.par.core.entity.AnonymousStat;
import com.par.core.enums.TrustLevel;
import com.par.core.mapper.AccountEmailMapper;
import com.par.core.mapper.AccountMapper;
import com.par.core.mapper.AnonymousStatMapper;
import com.par.core.service.AccountService;
@@ -16,6 +18,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 账户服务实现
*/
@@ -26,6 +34,9 @@ public class AccountServiceImpl implements AccountService {
private final AccountMapper accountMapper;
private final AnonymousStatMapper anonymousStatMapper;
private final AccountEmailMapper accountEmailMapper;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
@Override
@Transactional
@@ -89,9 +100,92 @@ 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) {
email = email.toLowerCase().trim();
AccountEmail existing = accountEmailMapper.selectByEmail(email);
if (existing != null) {
throw new IllegalArgumentException("该邮箱已被绑定: " + email);
}
AccountEmail ae = new AccountEmail();
ae.setAccountId(accountId);
ae.setEmail(email);
ae.setVerified(true);
accountEmailMapper.insert(ae);
}
@Override
@Transactional
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) {
throw new IllegalArgumentException("不能解绑唯一邮箱");
}
accountEmailMapper.deleteById(emailId);
}
@Override
public List<String> listEmails(Long accountId) {
Account account = accountMapper.selectById(accountId);
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();
}
/**
* 注册时关联匿名统计记录
*/

View File

@@ -0,0 +1,29 @@
-- PAR (PT Adapter Registry) Schema - Phase 3
-- Flyway Migration V3: API Key auth + multi-email support
-- accounts 表新增 api_key + api_secret
ALTER TABLE accounts
ADD api_key VARCHAR(64) NULL UNIQUE COMMENT 'API 密钥(public)',
ADD api_secret VARCHAR(128) NULL COMMENT 'API 密钥(secret,用于 HMAC 签名)';
-- 为已有管理员账号生成默认 key
UPDATE accounts SET
api_key = CONCAT('par_', LOWER(LEFT(MD5(RAND()), 16))),
api_secret = LEFT(SHA2(CONCAT(email, RAND()), 256), 64)
WHERE api_key IS NULL AND trust_level = 2;
-- 多邮箱关联表
CREATE TABLE account_emails (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
account_id BIGINT UNSIGNED NOT NULL COMMENT '关联账户ID',
email VARCHAR(255) NOT NULL COMMENT '邮箱地址',
verified TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否已验证',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
PRIMARY KEY (id),
UNIQUE KEY uk_email (email),
KEY idx_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账户多邮箱关联表';
-- 现有邮箱迁移到关联表
INSERT INTO account_emails (account_id, email, verified)
SELECT id, email, 1 FROM accounts WHERE deleted = 0;