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:
@@ -18,6 +18,8 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理员控制器
|
||||
@@ -153,6 +155,72 @@ public class AdminController {
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成/重置 API Key
|
||||
*/
|
||||
@PostMapping("/accounts/{accountId}/api-key")
|
||||
public ApiResponse<Map<String, String>> generateApiKey(
|
||||
@PathVariable Long accountId,
|
||||
HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
|
||||
var account = accountMapper.selectById(accountId);
|
||||
if (account == null) {
|
||||
return ApiResponse.error(404, "Account not found");
|
||||
}
|
||||
|
||||
String[] keys = accountService.generateApiCredentials(accountId);
|
||||
Map<String, String> result = new java.util.LinkedHashMap<>();
|
||||
result.put("apiKey", keys[0]);
|
||||
result.put("apiSecret", keys[1]);
|
||||
// secret 只在生成时返回一次,前端需要立即保存
|
||||
log.info("API key generated for account: id={}", accountId);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定额外邮箱
|
||||
*/
|
||||
@PostMapping("/accounts/{accountId}/emails")
|
||||
public ApiResponse<Void> bindEmail(
|
||||
@PathVariable Long accountId,
|
||||
@RequestBody Map<String, String> payload,
|
||||
HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
String email = payload.get("email");
|
||||
if (email == null || email.isBlank()) {
|
||||
return ApiResponse.error(400, "email is required");
|
||||
}
|
||||
accountService.bindEmail(accountId, email);
|
||||
log.info("Email bound to account: accountId={}, email={}", accountId, email);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑邮箱
|
||||
*/
|
||||
@DeleteMapping("/accounts/{accountId}/emails/{emailId}")
|
||||
public ApiResponse<Void> unbindEmail(
|
||||
@PathVariable Long accountId,
|
||||
@PathVariable Long emailId,
|
||||
HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
accountService.unbindEmail(emailId);
|
||||
log.info("Email unbound: accountId={}, emailId={}", accountId, emailId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户的绑定邮箱列表
|
||||
*/
|
||||
@GetMapping("/accounts/{accountId}/emails")
|
||||
public ApiResponse<List<String>> listEmails(
|
||||
@PathVariable Long accountId,
|
||||
HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
return ApiResponse.success(accountService.listEmails(accountId));
|
||||
}
|
||||
|
||||
private void checkAdmin(HttpServletRequest request) {
|
||||
TrustLevel level = (TrustLevel) request.getAttribute("trustLevel");
|
||||
if (level == null || level != TrustLevel.ADMIN) {
|
||||
|
||||
@@ -22,4 +22,7 @@ public class AccountDTO {
|
||||
|
||||
/** 登录 token(仅登录接口返回) */
|
||||
private String token;
|
||||
|
||||
/** API Key(管理员可见,用于外部集成) */
|
||||
private String apiKey;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
29
par-core/src/main/java/com/par/core/entity/AccountEmail.java
Normal file
29
par-core/src/main/java/com/par/core/entity/AccountEmail.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册时关联匿名统计记录
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -44,7 +44,8 @@ export default {
|
||||
},
|
||||
users: {
|
||||
list: () => api.get('/admin/accounts'),
|
||||
setTrustLevel: (id, level) => api.post(`/admin/accounts/${id}/trust-level`, null, { params: { level } })
|
||||
setTrustLevel: (id, level) => api.post(`/admin/accounts/${id}/trust-level`, null, { params: { level } }),
|
||||
genApiKey: (id) => api.post(`/admin/accounts/${id}/api-key`)
|
||||
},
|
||||
health: () => api.get('/health', { baseURL: '' })
|
||||
}
|
||||
|
||||
@@ -3,37 +3,69 @@
|
||||
<h2>用户管理</h2>
|
||||
<el-card style="margin-top: 20px">
|
||||
<el-table :data="users" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column prop="displayName" label="昵称" width="150" />
|
||||
<el-table-column prop="trustLevel" label="信任等级" width="100">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="displayName" label="昵称" width="120" />
|
||||
<el-table-column label="API Key" min-width="180">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.apiKey" style="font-family:monospace;font-size:12px">{{ scope.row.apiKey }}</span>
|
||||
<el-tag v-else size="small" type="info">未生成</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="trustLevel" label="信任等级" width="90">
|
||||
<template #default="scope">
|
||||
<el-tag :type="trustLevelType(scope.row.trustLevel)">
|
||||
{{ trustLevelLabel(scope.row.trustLevel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isActive" label="状态" width="80">
|
||||
<el-table-column prop="isActive" label="状态" width="70">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isActive ? 'success' : 'danger'">
|
||||
{{ scope.row.isActive ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="注册时间" width="180" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column prop="createdAt" label="注册时间" width="160" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleGenKey(scope.row)">生成密钥</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.trustLevel !== 'ADMIN'"
|
||||
size="small"
|
||||
@click="handleSetLevel(scope.row)"
|
||||
>设置等级</el-button>
|
||||
<span v-else style="color:#999;font-size:12px">管理员</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- API Key 弹窗 -->
|
||||
<el-dialog v-model="keyDialogVisible" title="API Key" width="500px" :close-on-click-modal="false">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||
Secret 仅在生成时显示一次,请立即复制保存!
|
||||
</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="keyResult.apiKey" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyText(keyResult.apiKey)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="API Secret">
|
||||
<el-input v-model="keyResult.apiSecret" readonly type="textarea" :rows="2">
|
||||
<template #append>
|
||||
<el-button @click="copyText(keyResult.apiSecret)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="keyDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 等级设置弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" title="设置信任等级" width="400px">
|
||||
<el-form :model="levelForm">
|
||||
@@ -63,6 +95,8 @@ const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const levelForm = reactive({ id: null, email: '', level: '' })
|
||||
const keyDialogVisible = ref(false)
|
||||
const keyResult = reactive({ apiKey: '', apiSecret: '' })
|
||||
|
||||
const trustLevelType = (level) => {
|
||||
const map = { MEMBER: '', TRUSTED: 'warning', ADMIN: 'danger' }
|
||||
@@ -86,6 +120,22 @@ const fetchUsers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenKey = async (row) => {
|
||||
try {
|
||||
const res = await api.users.genApiKey(row.id)
|
||||
keyResult.apiKey = res.data.apiKey
|
||||
keyResult.apiSecret = res.data.apiSecret
|
||||
keyDialogVisible.value = true
|
||||
fetchUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = (text) => {
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
const handleSetLevel = (row) => {
|
||||
levelForm.id = row.id
|
||||
levelForm.email = row.email
|
||||
|
||||
Reference in New Issue
Block a user