diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index bb44c163..7894d01a 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -6,9 +6,17 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Base64; import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.agent.AgentService; import vip.mate.channel.model.ChannelEntity; @@ -17,7 +25,9 @@ import vip.mate.channel.web.ChatStreamTracker; import vip.mate.common.result.R; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.vo.MessageVO; import java.io.IOException; import java.time.LocalDateTime; @@ -26,6 +36,8 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * WebChat 嵌入式对话接口 @@ -52,6 +64,13 @@ public class WebChatController { private final ConversationCompletionPublisher completionPublisher; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; + /** + * Server-only secret used to sign per-visitor tokens. Reuses the JWT secret so no extra + * config/migration is needed; it is never sent to the client (unlike the public channel API key). + */ + @Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}") + private String visitorTokenSecret; + private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); /** @@ -101,18 +120,17 @@ public class WebChatController { // Optional sessionId lets one visitor hold multiple isolated threads. It is only ever // composed into the server-derived conversationId (kept under the key+visitor namespace), // never accepted as a raw conversationId — so a caller can't reach another tenant's history. - String sessionId = request.getSessionId() != null ? request.getSessionId().trim() : null; - if (sessionId != null && !sessionId.isEmpty() && !sessionId.matches("[A-Za-z0-9_-]{1,64}")) { - sendErrorAndComplete(emitter, "Invalid sessionId (allowed: letters, digits, '-', '_', length 1-64)"); + final String effectiveSessionId; + try { + effectiveSessionId = normalizeSessionId(request.getSessionId()); + } catch (IllegalArgumentException ex) { + sendErrorAndComplete(emitter, ex.getMessage()); return emitter; } - if (sessionId != null && sessionId.isEmpty()) { - sessionId = null; - } - - final String effectiveSessionId = sessionId; - String conversationId = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":" + visitorId - + (effectiveSessionId != null ? ":" + effectiveSessionId : ""); + String conversationId = deriveConversationId(apiKey, visitorId, effectiveSessionId); + // Server-issued, unforgeable proof that this caller owns this visitorId. Returned in the + // meta event below; the session-management endpoints require it back (see verifyVisitorToken). + final String visitorToken = computeVisitorToken(visitorTokenSecret, channel.getId(), visitorId); String message = request.getMessage() != null ? request.getMessage() : ""; if (message.isBlank()) { @@ -148,10 +166,12 @@ public class WebChatController { streamTracker.attach(conversationId, emitter); // Echo the effective session so the caller can persist it (especially when - // sessionId was omitted) and address the same thread on subsequent calls. + // sessionId was omitted) and address the same thread on subsequent calls. The + // visitorToken must be stored by the caller and sent back on list/messages/delete. streamTracker.broadcast(conversationId, "meta", "{\"sessionId\":" + escapeJson(effectiveSessionId) - + ",\"conversationId\":" + escapeJson(conversationId) + "}"); + + ",\"conversationId\":" + escapeJson(conversationId) + + ",\"visitorToken\":" + escapeJson(visitorToken) + "}"); // Accumulate the assistant reply so it can be persisted on stream completion. // Pattern mirrors ChatController: always accumulate, only broadcast when the @@ -255,8 +275,168 @@ public class WebChatController { )); } + /** + * 列出某访客的会话线程 + *
+ * 仅返回属于本 Key + visitorId 的会话(按 conversationId 前缀过滤),
+ * 不暴露裸 conversationId,调用方按 sessionId 寻址。
+ */
+ @Operation(summary = "列出访客会话线程")
+ @GetMapping("/sessions")
+ public R 注意:这不是鉴权边界——conversationId 由调用方自报的 visitorId 派生,
+ * 等式两边同源,单凭它无法防越权。真正的鉴权由 {@link #verifyVisitorToken} 完成。
+ */
+ private boolean ownsConversation(String conversationId, String visitorId) {
+ ConversationEntity conv = conversationService.findByConversationId(conversationId);
+ return conv != null && ("webchat:" + visitorId).equals(conv.getUsername());
+ }
+
+ /**
+ * 用服务端密钥对 (channelId, visitorId) 做 HMAC-SHA256,签发不可伪造的 visitor token。
+ * 载荷含 channelId,使 token 不能跨渠道复用。
+ */
+ static String computeVisitorToken(String secret, Long channelId, String visitorId) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ byte[] sig = mac.doFinal((channelId + ":" + visitorId).getBytes(StandardCharsets.UTF_8));
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(sig);
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("HMAC-SHA256 unavailable", e);
+ }
+ }
+
+ /**
+ * 常量时间校验调用方回传的 token:缺失/不匹配均返回 false。
+ */
+ static boolean verifyVisitorToken(String secret, Long channelId, String visitorId, String presented) {
+ if (presented == null || presented.isEmpty() || visitorId == null || channelId == null) {
+ return false;
+ }
+ byte[] expected = computeVisitorToken(secret, channelId, visitorId).getBytes(StandardCharsets.UTF_8);
+ byte[] actual = presented.getBytes(StandardCharsets.UTF_8);
+ return MessageDigest.isEqual(expected, actual);
+ }
+
/**
* 通过 API Key 查找 WebChat 渠道
*/
@@ -337,4 +517,15 @@ public class WebChatController {
* Composed into the server-derived conversationId; never used as a raw conversationId. */
private String sessionId;
}
+
+ /** Compact view of one of a visitor's conversation threads. */
+ @lombok.Data
+ @lombok.AllArgsConstructor
+ public static class WebChatSessionView {
+ /** null for the visitor's default (no-session) thread. */
+ private String sessionId;
+ private String title;
+ private LocalDateTime lastActiveTime;
+ private Integer messageCount;
+ }
}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java
new file mode 100644
index 00000000..571d89be
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java
@@ -0,0 +1,90 @@
+package vip.mate.channel.webchat;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * PR #297 P1 IDOR 修复回归测试:list/messages/delete 端点的鉴权不能再只靠调用方自报的 visitorId,
+ * 必须验证服务端用密钥签发的 visitor token。这里覆盖 token 的签发/校验语义。
+ */
+class WebChatVisitorTokenTest {
+
+ private static final String SECRET = "test-secret-do-not-use-in-prod";
+ private static final Long CHANNEL = 7L;
+ private static final String VISITOR = "visitor-abc";
+
+ // ==================== 签发 ====================
+
+ @Test
+ void token_isDeterministic_forSameInputs() {
+ assertEquals(
+ WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR),
+ WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR));
+ }
+
+ @Test
+ void token_differsPerVisitor() {
+ assertNotEquals(
+ WebChatController.computeVisitorToken(SECRET, CHANNEL, "alice"),
+ WebChatController.computeVisitorToken(SECRET, CHANNEL, "bob"));
+ }
+
+ @Test
+ void token_isChannelBound_notPortable() {
+ // 同一 visitorId 在不同渠道下 token 不同 → 持 A 渠道 token 不能操作 B 渠道同名 visitor。
+ assertNotEquals(
+ WebChatController.computeVisitorToken(SECRET, 1L, VISITOR),
+ WebChatController.computeVisitorToken(SECRET, 2L, VISITOR));
+ }
+
+ @Test
+ void token_dependsOnSecret() {
+ assertNotEquals(
+ WebChatController.computeVisitorToken("secret-a", CHANNEL, VISITOR),
+ WebChatController.computeVisitorToken("secret-b", CHANNEL, VISITOR));
+ }
+
+ // ==================== 校验 ====================
+
+ @Test
+ void verify_acceptsTokenIssuedForSameVisitor() {
+ String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR);
+ assertTrue(WebChatController.verifyVisitorToken(SECRET, CHANNEL, VISITOR, token));
+ }
+
+ @Test
+ void verify_rejectsForgedVisitorIdWithoutToken() {
+ // 攻击者持公开 key,传受害者 visitorId,但拿不到对应 token。
+ assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", null));
+ assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", ""));
+ }
+
+ @Test
+ void verify_rejectsTokenMintedForAnotherVisitor() {
+ // 攻击者拿自己 visitor 的合法 token,去操作受害者 visitor → 必须失败。
+ String attackerToken = WebChatController.computeVisitorToken(SECRET, CHANNEL, "attacker");
+ assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", attackerToken));
+ }
+
+ @Test
+ void verify_rejectsTokenFromAnotherChannel() {
+ String tokenForChannel1 = WebChatController.computeVisitorToken(SECRET, 1L, VISITOR);
+ assertFalse(WebChatController.verifyVisitorToken(SECRET, 2L, VISITOR, tokenForChannel1));
+ }
+
+ @Test
+ void verify_rejectsTamperedToken() {
+ String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR);
+ String tampered = token.substring(0, token.length() - 1)
+ + (token.endsWith("A") ? "B" : "A");
+ assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, VISITOR, tampered));
+ }
+
+ @Test
+ void verify_rejectsNullChannelOrVisitor() {
+ String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR);
+ assertFalse(WebChatController.verifyVisitorToken(SECRET, null, VISITOR, token));
+ assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, null, token));
+ }
+}
> listSessions(
+ @RequestHeader("X-MC-Key") String apiKey,
+ @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
+ @RequestParam String visitorId) {
+ ChannelEntity channel = resolveChannel(apiKey);
+ if (channel == null) {
+ return R.fail(401, "Invalid API Key");
+ }
+ if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
+ return R.fail(401, "Invalid or missing visitor token");
+ }
+ String base = deriveConversationId(apiKey, visitorId, null);
+ String prefix = base + ":";
+ List
> sessionMessages(
+ @RequestHeader("X-MC-Key") String apiKey,
+ @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
+ @RequestParam String visitorId,
+ @RequestParam(required = false) String sessionId) {
+ ChannelEntity channel = resolveChannel(apiKey);
+ if (channel == null) {
+ return R.fail(401, "Invalid API Key");
+ }
+ if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
+ return R.fail(401, "Invalid or missing visitor token");
+ }
+ String sid;
+ try {
+ sid = normalizeSessionId(sessionId);
+ } catch (IllegalArgumentException ex) {
+ return R.fail(400, ex.getMessage());
+ }
+ String conversationId = deriveConversationId(apiKey, visitorId, sid);
+ if (!ownsConversation(conversationId, visitorId)) {
+ return R.fail(404, "Session not found");
+ }
+ return R.ok(conversationService.listMessageViews(conversationId));
+ }
+
+ /**
+ * 删除某会话线程
+ */
+ @Operation(summary = "删除会话线程")
+ @DeleteMapping("/sessions")
+ public R