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:
32
par-core/src/main/java/com/par/core/entity/Psk.java
Normal file
32
par-core/src/main/java/com/par/core/entity/Psk.java
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
18
par-core/src/main/java/com/par/core/mapper/PskMapper.java
Normal file
18
par-core/src/main/java/com/par/core/mapper/PskMapper.java
Normal 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();
|
||||
}
|
||||
103
par-core/src/main/java/com/par/core/service/PskService.java
Normal file
103
par-core/src/main/java/com/par/core/service/PskService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
11
par-core/src/main/resources/db/migration/V4__psk_table.sql
Normal file
11
par-core/src/main/resources/db/migration/V4__psk_table.sql
Normal 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 版本管理表';
|
||||
Reference in New Issue
Block a user