mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
12c24651a2
commit
77b6baeccc
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user