feat(channel): webchat session-management endpoints (list / messages / delete)

Add per-visitor session management for the WebChat Web/API access mode:
list a visitor's conversation threads, fetch a thread's messages, and
delete a thread.

Authorization: visitorId is a client-asserted request param, so it cannot
be trusted on its own — deriving conversationId from it and then checking
ownership against it is tautological (any caller passes). Instead, /stream
issues a per-visitor token = HMAC-SHA256(jwtSecret, channelId:visitorId),
returned in the meta event; the management endpoints require it back via
the X-MC-Visitor-Token header and verify it in constant time. The signing
secret is server-only (unlike the public channel API key) and the channelId
in the payload makes tokens non-portable across channels.

Includes regression tests for token issuance/verification semantics
(forged visitorId rejected, cross-visitor and cross-channel tokens rejected,
tampered tokens rejected).
This commit is contained in:
倪程伟 2026-06-08 23:52:25 +08:00 committed by matevip
parent 12c24651a2
commit 77b6baeccc
2 changed files with 293 additions and 12 deletions

View File

@ -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 {
));
}
/**
* 列出某访客的会话线程
* <p>
* 仅返回属于本 Key + visitorId 的会话 conversationId 前缀过滤
* 不暴露裸 conversationId调用方按 sessionId 寻址
*/
@Operation(summary = "列出访客会话线程")
@GetMapping("/sessions")
public R<List<WebChatSessionView>> 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<WebChatSessionView> 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<List<MessageVO>> 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<Void> 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
* <p>注意<b>不是</b>鉴权边界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;
}
}

View File

@ -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));
}
}