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> 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 sessions = conversationService.listConversations("webchat:" + visitorId).stream() + .filter(c -> c.getConversationId() != null + && (c.getConversationId().equals(base) || c.getConversationId().startsWith(prefix))) + .map(c -> { + String cid = c.getConversationId(); + String sid = cid.equals(base) ? null : cid.substring(prefix.length()); + return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount()); + }) + .collect(Collectors.toList()); + return R.ok(sessions); + } + + /** + * 获取某会话线程的消息列表 + */ + @Operation(summary = "获取会话消息") + @GetMapping("/sessions/messages") + public R> 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 deleteSession( + @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"); + } + conversationService.deleteConversation(conversationId); + return R.ok(); + } + // ==================== 内部方法 ==================== + private static final Pattern SESSION_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + + /** + * 归一化调用方传入的 sessionId:空白 → null;非空必须满足白名单字符集,否则抛出。 + */ + private String normalizeSessionId(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim(); + if (s.isEmpty()) { + return null; + } + if (!SESSION_ID_PATTERN.matcher(s).matches()) { + throw new IllegalArgumentException( + "Invalid sessionId (allowed: letters, digits, '-', '_', length 1-64)"); + } + return s; + } + + /** + * 由服务端拼装 conversationId,始终钳在 key + visitor 命名空间内。 + * 绝不接受调用方传入的裸 conversationId。 + */ + private String deriveConversationId(String apiKey, String visitorId, String sessionId) { + String base = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":" + visitorId; + return sessionId != null ? base + ":" + sessionId : base; + } + + /** + * 存在性守卫:会话存在且属于本 visitor 命名空间时返回 true,否则 404。 + *

注意:这不是鉴权边界——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)); + } +}