diff --git a/docker-compose.yml b/docker-compose.yml index 15760ba..95b9197 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: MYSQL_USER: "${MYSQL_USER:-root}" MYSQL_PASSWORD: "${MYSQL_PASSWORD:-root}" HMAC_SECRET: "${HMAC_SECRET:-}" + PAR_MEDIABOT_PSK: "${PAR_MEDIABOT_PSK:-}" PAR_STATIC_PATH: "/app/static" PAR_ADMIN_PATH: "/app/static/admin" extra_hosts: diff --git a/par-api/src/main/java/com/par/api/controller/AdminController.java b/par-api/src/main/java/com/par/api/controller/AdminController.java index 77b6abd..976dff3 100644 --- a/par-api/src/main/java/com/par/api/controller/AdminController.java +++ b/par-api/src/main/java/com/par/api/controller/AdminController.java @@ -10,7 +10,9 @@ import com.par.core.mapper.AccountMapper; import com.par.core.mapper.ConfigReviewMapper; import com.par.core.mapper.SiteConfigMapper; import com.par.core.mapper.SiteMapper; +import com.par.core.entity.Psk; import com.par.core.service.AccountService; +import com.par.core.service.PskService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Map; import java.util.List; /** @@ -36,6 +39,7 @@ public class AdminController { private final AccountMapper accountMapper; private final SiteMapper siteMapper; private final AccountService accountService; + private final PskService pskService; /** * 获取所有用户列表 @@ -221,6 +225,34 @@ public class AdminController { return ApiResponse.success(accountService.listEmails(accountId)); } + // ===================== PSK 管理 ===================== + + @GetMapping("/psks") + public ApiResponse> listPsks(HttpServletRequest request) { + checkAdmin(request); + return ApiResponse.success(pskService.listAll()); + } + + @PostMapping("/psks") + public ApiResponse addPsk(@RequestBody Map body, HttpServletRequest request) { + checkAdmin(request); + String version = body.get("version"); + String secret = body.get("secret"); + String description = body.get("description"); + if (version == null || secret == null) { + return ApiResponse.error(400, "version and secret are required"); + } + return ApiResponse.success(pskService.add(version, secret, description)); + } + + @PostMapping("/psks/{id}/toggle") + public ApiResponse togglePsk(@PathVariable Long id, @RequestParam("active") boolean active, + HttpServletRequest request) { + checkAdmin(request); + pskService.toggle(id, active); + return ApiResponse.success(); + } + private void checkAdmin(HttpServletRequest request) { TrustLevel level = (TrustLevel) request.getAttribute("trustLevel"); if (level == null || level != TrustLevel.ADMIN) { diff --git a/par-api/src/main/java/com/par/api/controller/AuthController.java b/par-api/src/main/java/com/par/api/controller/AuthController.java index ee89032..836bf48 100644 --- a/par-api/src/main/java/com/par/api/controller/AuthController.java +++ b/par-api/src/main/java/com/par/api/controller/AuthController.java @@ -1,6 +1,5 @@ package com.par.api.controller; -import com.par.common.util.HmacUtil; import com.par.core.dto.AccountDTO; import com.par.core.dto.ApiResponse; import com.par.core.dto.LoginRequest; @@ -8,10 +7,10 @@ import com.par.core.dto.RegisterRequest; import com.par.core.entity.Account; import com.par.core.interceptor.HmacAuthInterceptor; import com.par.core.service.AccountService; +import com.par.core.service.PskService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import java.util.LinkedHashMap; @@ -27,13 +26,13 @@ public class AuthController { private final AccountService accountService; private final HmacAuthInterceptor hmacAuthInterceptor; + private final PskService pskService; - @Value("${par.mediabot.psk:}") - private String mediabotPsk; - - public AuthController(AccountService accountService, HmacAuthInterceptor hmacAuthInterceptor) { + public AuthController(AccountService accountService, HmacAuthInterceptor hmacAuthInterceptor, + PskService pskService) { this.accountService = accountService; this.hmacAuthInterceptor = hmacAuthInterceptor; + this.pskService = pskService; } /** @@ -116,15 +115,14 @@ public class AuthController { } /** - * 验证 X-Medibot-Timestamp + X-Medibot-Signature + * 验证 X-Medibot-Timestamp + X-Medibot-Signature(通过 PskService) */ private boolean verifyMediabotPsk(HttpServletRequest request, String email) { String ts = request.getHeader("X-Medibot-Timestamp"); String sig = request.getHeader("X-Medibot-Signature"); if (ts == null || sig == null) return false; try { - long timestamp = Long.parseLong(ts); - return HmacUtil.verifyMediabotPsk(email, timestamp, sig, mediabotPsk); + return pskService.verify(email, Long.parseLong(ts), sig) != null; } catch (NumberFormatException e) { return false; } diff --git a/par-core/src/main/java/com/par/core/entity/Psk.java b/par-core/src/main/java/com/par/core/entity/Psk.java new file mode 100644 index 0000000..4fca50c --- /dev/null +++ b/par-core/src/main/java/com/par/core/entity/Psk.java @@ -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; +} diff --git a/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java b/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java index d2b9539..0f7dc2a 100644 --- a/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java +++ b/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java @@ -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 { diff --git a/par-core/src/main/java/com/par/core/mapper/PskMapper.java b/par-core/src/main/java/com/par/core/mapper/PskMapper.java new file mode 100644 index 0000000..1dc830b --- /dev/null +++ b/par-core/src/main/java/com/par/core/mapper/PskMapper.java @@ -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 { + + @Select("SELECT * FROM psks WHERE is_active = 1") + List selectActive(); +} diff --git a/par-core/src/main/java/com/par/core/service/PskService.java b/par-core/src/main/java/com/par/core/service/PskService.java new file mode 100644 index 0000000..c86e158 --- /dev/null +++ b/par-core/src/main/java/com/par/core/service/PskService.java @@ -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 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 listActive() { + return pskMapper.selectActive(); + } + + /** + * 获取所有 PSK(含禁用的) + */ + public List 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; + } +} diff --git a/par-core/src/main/resources/db/migration/V4__psk_table.sql b/par-core/src/main/resources/db/migration/V4__psk_table.sql new file mode 100644 index 0000000..f556d49 --- /dev/null +++ b/par-core/src/main/resources/db/migration/V4__psk_table.sql @@ -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 版本管理表';