feat: PSK 版本化管理 + DB 表 + 管理后台增删启禁

- V4 迁移: psks 表 (version, secret, description, is_active)
- PskService: 启动导入 PAR_MEDIABOT_PSK → v1, verify 遍历所有活跃 PSK
- HmacAuthInterceptor: PSK 签名优先验签,精简为 Bearer/PSK 双路径
- AdminController: GET/POST /psks + toggle 启禁
- AuthController: register/recover 改用 PskService 验证
- docker-compose.yml 新增 PAR_MEDIABOT_PSK 环境变量

mediabot 端只需: X-Signature = HMAC(PSK, 'email:ts')
This commit is contained in:
mediabot-pt
2026-06-29 16:52:20 +08:00
parent 11d0840e42
commit 5304449de6
8 changed files with 224 additions and 72 deletions

View File

@@ -0,0 +1,32 @@
package com.par.core.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* PSK 密钥实体
*/
@Data
@TableName("psks")
public class Psk {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("version")
private String version;
@TableField("secret")
private String secret;
@TableField("description")
private String description;
@TableField("is_active")
private Boolean isActive;
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}

View File

@@ -3,6 +3,7 @@ package com.par.core.interceptor;
import com.par.common.util.HmacUtil;
import com.par.core.entity.Account;
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;
@@ -14,7 +15,6 @@ import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.stream.Collectors;
/**
* API 鉴权拦截器
@@ -29,6 +29,7 @@ import java.util.stream.Collectors;
public class HmacAuthInterceptor implements HandlerInterceptor {
private final AccountService accountService;
private final PskService pskService;
@Value("${hmac.secret}")
private String hmacSecret;
@@ -62,72 +63,28 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
return false;
}
// ======== 方式 2:HMAC 签名认证(外部 API 客户端) ========
String signature = request.getHeader(HEADER_SIGNATURE);
String timestampStr = request.getHeader(HEADER_TIMESTAMP);
// ======== 方式 2:PSK 签名认证(mediabot 等集成客户端) ========
String pskSig = request.getHeader(HEADER_SIGNATURE);
String ts = request.getHeader(HEADER_TIMESTAMP);
String email = request.getHeader(HEADER_EMAIL);
if (signature == null || timestampStr == null || email == null) {
writeError(response, 401, "Missing authentication headers");
if (pskSig != null && ts != null && email != null) {
try {
long timestamp = Long.parseLong(ts);
String pskVersion = pskService.verify(email, timestamp, pskSig);
if (pskVersion != null) {
request.setAttribute("accountId", 0L);
request.setAttribute("trustLevel", com.par.core.enums.TrustLevel.TRUSTED);
request.setAttribute("pskVersion", pskVersion);
return true;
}
} catch (NumberFormatException ignored) {}
writeError(response, 401, "Invalid PSK signature");
return false;
}
// 校验时间戳(防重放)
long timestamp;
try {
timestamp = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
writeError(response, 401, "Invalid timestamp");
return false;
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) > HMAC_WINDOW_SECONDS) {
writeError(response, 401, "Request expired or clock skew too large");
return false;
}
// 查找账户(支持主邮箱和关联邮箱)
Account account = accountService.findByAnyEmail(email);
// 计算 body hash
String body = readBody(request);
String bodyHash = HmacUtil.sha256(body);
// 构建签名内容
String signContent = HmacUtil.buildSignContent(
request.getMethod(),
request.getRequestURI(),
timestamp,
bodyHash
);
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;
}
// 将账户ID写入请求属性,供后续使用
request.setAttribute("accountId", account.getId());
request.setAttribute("trustLevel", account.getTrustLevel());
return true;
// ======== 方式 3:无有效认证头 → 拒绝 ========
writeError(response, 401, "Missing or invalid authentication");
return false;
}
private String readBody(HttpServletRequest request) throws IOException {

View File

@@ -0,0 +1,18 @@
package com.par.core.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.par.core.entity.Psk;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* PSK Mapper
*/
@Mapper
public interface PskMapper extends BaseMapper<Psk> {
@Select("SELECT * FROM psks WHERE is_active = 1")
List<Psk> selectActive();
}

View File

@@ -0,0 +1,103 @@
package com.par.core.service;
import com.par.common.util.HmacUtil;
import com.par.core.entity.Psk;
import com.par.core.mapper.PskMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* PSK 管理服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PskService {
private final PskMapper pskMapper;
@Value("${par.mediabot.psk:}")
private String envPsk;
/**
* 启动时:如果配置了环境变量 PSK 且 DB 中不存在,自动导入为 v1
*/
@EventListener(ApplicationReadyEvent.class)
public void initDefaultPsk() {
if (envPsk == null || envPsk.isBlank()) return;
List<Psk> all = pskMapper.selectList(null);
// 检查是否有相同 secret 的 PSK 已存在
boolean exists = all.stream().anyMatch(p -> envPsk.equals(p.getSecret()));
if (exists) return;
Psk psk = new Psk();
psk.setVersion(all.isEmpty() ? "v1" : "v" + (all.size() + 1));
psk.setSecret(envPsk);
psk.setDescription("Imported from PAR_MEDIABOT_PSK");
psk.setIsActive(true);
pskMapper.insert(psk);
log.info("Imported PSK {} from environment", psk.getVersion());
}
/**
* 获取所有活跃 PSK
*/
public List<Psk> listActive() {
return pskMapper.selectActive();
}
/**
* 获取所有 PSK(含禁用的)
*/
public List<Psk> listAll() {
return pskMapper.selectList(null);
}
/**
* 添加 PSK
*/
public Psk add(String version, String secret, String description) {
Psk psk = new Psk();
psk.setVersion(version);
psk.setSecret(secret);
psk.setDescription(description);
psk.setIsActive(true);
pskMapper.insert(psk);
return psk;
}
/**
* 启用/禁用
*/
public void toggle(Long id, boolean active) {
Psk psk = pskMapper.selectById(id);
if (psk == null) throw new IllegalArgumentException("PSK not found");
psk.setIsActive(active);
pskMapper.updateById(psk);
}
/**
* 使用所有活跃 PSK 逐一验证签名
* @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";
}
}
return null;
}
}

View File

@@ -0,0 +1,11 @@
-- PAR Flyway V4: PSK 版本管理
CREATE TABLE psks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
version VARCHAR(20) NOT NULL COMMENT 'PSK 版本号(如 v1, v2)',
secret VARCHAR(255) NOT NULL COMMENT 'PSK 密钥值',
description VARCHAR(200) COMMENT '描述(哪个客户端在用)',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_version (version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PSK 版本管理表';