diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatAdminController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatAdminController.java new file mode 100644 index 00000000..7e16dace --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatAdminController.java @@ -0,0 +1,104 @@ +package vip.mate.channel.webchat; + +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.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.service.ChannelService; +import vip.mate.common.result.R; + +import java.util.Map; + +/** + * Admin-facing webchat operations. Mounted under {@code /api/v1/admin/webchat/**} + * (outside the {@code /api/v1/channels/webchat/**} permitAll block) so it + * requires a regular MateClaw JWT — visitors cannot reach these endpoints. + * + *

Currently only manages visitor-token revocation. Audit-recorded via + * {@link AuditEventService}; actor is the JWT-authenticated admin username, + * not the visitor. + * + * @author MateClaw Team + */ +@Tag(name = "WebChat 管理(管理员)") +@Slf4j +@RestController +@RequestMapping("/api/v1/admin/webchat") +@RequiredArgsConstructor +public class WebChatAdminController { + + private final ChannelService channelService; + private final WebChatTokenRevocationService revocationService; + private final AuditEventService auditService; + + @Operation(summary = "撤销访客的 visitorToken(ban 该 visitor 在管理端点的所有调用)") + @PostMapping("/revoked-visitor") + public R revokeVisitor( + @RequestBody RevokeVisitorRequest request, + Authentication auth) { + if (request == null || request.getChannelId() == null + || request.getVisitorId() == null || request.getVisitorId().isBlank()) { + return R.fail(400, "channelId and visitorId are required"); + } + ChannelEntity channel = channelService.getChannel(request.getChannelId()); + if (channel == null || !"webchat".equals(channel.getChannelType())) { + return R.fail(404, "webchat channel not found"); + } + revocationService.revoke(channel.getId(), request.getVisitorId().trim(), + request.getReason()); + + String adminUser = auth != null ? auth.getName() : "system"; + auditService.record( + "webchat.revoke-visitor", + "CHANNEL", + String.valueOf(channel.getId()), + channel.getName(), + "{\"visitorId\":\"" + request.getVisitorId().trim() + + "\",\"reason\":\"" + (request.getReason() != null ? request.getReason() : "") + + "\",\"admin\":\"" + adminUser + "\"}", + channel.getWorkspaceId()); + return R.ok(); + } + + @Operation(summary = "取消撤销访客(un-ban)") + @DeleteMapping("/revoked-visitor") + public R unrevokeVisitor( + @RequestBody Map body, + Authentication auth) { + Long channelId = body.get("channelId") instanceof Number n ? n.longValue() : null; + Object rawChannelId = body.get("channelId"); + if (rawChannelId instanceof String s && !s.isBlank()) { + try { channelId = Long.parseLong(s); } catch (NumberFormatException ignored) { } + } + String visitorId = body.get("visitorId") instanceof String s ? s.trim() : null; + if (channelId == null || visitorId == null || visitorId.isEmpty()) { + return R.fail(400, "channelId and visitorId are required"); + } + revocationService.unrevoke(channelId, visitorId); + + String adminUser = auth != null ? auth.getName() : "system"; + auditService.record( + "webchat.unrevoke-visitor", + "CHANNEL", + String.valueOf(channelId), + null, + "{\"visitorId\":\"" + visitorId + "\",\"admin\":\"" + adminUser + "\"}", + null); + return R.ok(); + } + + @lombok.Data + public static class RevokeVisitorRequest { + private Long channelId; + private String visitorId; + private String reason; + } +} 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 03d1bf81..44d364e9 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 @@ -73,6 +73,10 @@ public class WebChatController { private final ConversationCompletionPublisher completionPublisher; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; private final WebChatFileService fileService; + private final WebChatTokenRevocationService tokenRevocationService; + + /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */ + static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; /** * Server-only secret used to sign per-visitor tokens. Reuses the JWT secret so no extra @@ -951,30 +955,73 @@ public class WebChatController { /** * 用服务端密钥对 (channelId, visitorId) 做 HMAC-SHA256,签发不可伪造的 visitor token。 * 载荷含 channelId,使 token 不能跨渠道复用。 + *

Token 默认 7 天后过期({@link #VISITOR_TOKEN_TTL_SECONDS});过期时间作为后缀 + * 明文附加在 HMAC 之后({@code .}),既参与签名也方便解析。 + * 过期后访客可通过 {@code /stream} 重新签发({@code /stream} 不校验 token,只签发)。 */ static String computeVisitorToken(String secret, Long channelId, String visitorId) { + return computeVisitorToken(secret, channelId, visitorId, + java.time.Instant.now().getEpochSecond() + VISITOR_TOKEN_TTL_SECONDS); + } + + /** Test/override hook: explicit expiration epoch second. */ + static String computeVisitorToken(String secret, Long channelId, String visitorId, long expiresAtEpochSecond) { 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); + String payload = channelId + ":" + visitorId + ":" + expiresAtEpochSecond; + byte[] sig = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(sig) + "." + expiresAtEpochSecond; } catch (GeneralSecurityException e) { throw new IllegalStateException("HMAC-SHA256 unavailable", e); } } /** - * 常量时间校验调用方回传的 token:缺失/不匹配均返回 false。 + * 校验调用方回传的 token 的签名 + 过期。不查撤销表(撤销是实例层职责, + * 见 {@link #verifyVisitorToken})。Static 是为了让单测可以直接验证 HMAC 语义, + * 不需要起 Spring context。 */ - static boolean verifyVisitorToken(String secret, Long channelId, String visitorId, String presented) { + static boolean verifyVisitorTokenSignature(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); + int dot = presented.lastIndexOf('.'); + if (dot <= 0 || dot == presented.length() - 1) { + return false; + } + long exp; + try { + exp = Long.parseLong(presented.substring(dot + 1)); + } catch (NumberFormatException e) { + return false; + } + if (java.time.Instant.now().getEpochSecond() >= exp) { + return false; + } + // Constant-time comparison of the full token (sig + ".exp"). HMAC covers + // both channelId:visitorId and exp, so any tampering with exp invalidates sig. + byte[] expected = computeVisitorToken(secret, channelId, visitorId, exp).getBytes(StandardCharsets.UTF_8); byte[] actual = presented.getBytes(StandardCharsets.UTF_8); return MessageDigest.isEqual(expected, actual); } + /** + * 完整校验:签名 + 过期 + 撤销。任一不通过返回 false。实例方法,接入 + * {@link WebChatTokenRevocationService}。{@code /stream} 第一次接触不调用本方法 + * (只签发 token,不校验),所以被撤销的 visitor 仍能发起新会话——撤销只让旧的 + * 管理 token 失效,符合 issue #351 的设计。 + */ + boolean verifyVisitorToken(String secret, Long channelId, String visitorId, String presented) { + if (!verifyVisitorTokenSignature(secret, channelId, visitorId, presented)) { + return false; + } + if (tokenRevocationService != null && tokenRevocationService.isRevoked(channelId, visitorId)) { + return false; + } + return true; + } + /** * 通过 API Key 查找 WebChat 渠道 */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatRevokedVisitorEntity.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatRevokedVisitorEntity.java new file mode 100644 index 00000000..1c92ee22 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatRevokedVisitorEntity.java @@ -0,0 +1,46 @@ +package vip.mate.channel.webchat; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Persistent registry of visitors whose {@code visitorToken} HMAC is no longer + * accepted on management endpoints (list/messages/title/delete/stop/upload/ + * regenerate). Created by V148. The unique constraint on + * {@code (channel_id, visitor_id, deleted)} makes re-revoke idempotent; + * setting {@code deleted = 1} un-revokes. + *

+ * {@code POST /stream} is intentionally NOT bound by this — a revoked visitor + * can still start a fresh {@code /stream}, which mints a new token; the + * revocation applies to the old token presented on management endpoints. + * + * @author MateClaw Team + */ +@Data +@TableName("webchat_revoked_visitor") +public class WebChatRevokedVisitorEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long channelId; + + /** Visitor identifier in the same charset as {@code WebChatController.normalizeVisitorId}. */ + private String visitorId; + + private LocalDateTime revokedAt; + + /** Free-form reason (admin-supplied). Nullable. */ + private String reason; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + /** 0 = active revocation; 1 = un-revoked (tombstoned). */ + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatTokenRevocationService.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatTokenRevocationService.java new file mode 100644 index 00000000..b9ffdff5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatTokenRevocationService.java @@ -0,0 +1,134 @@ +package vip.mate.channel.webchat; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.channel.webchat.repository.WebChatRevokedVisitorMapper; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * Visitor-token revocation lookup with a process-local Caffeine cache in front + * of the {@code webchat_revoked_visitor} table. + * + *

The cache is best-effort: a revoked visitor may take up to + * {@link #CACHE_TTL} to become effectively revoked on a node that has the + * un-revoked entry cached. We accept that window — webchat is low-volume — + * rather than pay a DB round-trip on every management endpoint call. For + * multi-instance deployments the same eventual-consistency applies + * independently per node; the DB remains the source of truth and a fresh + * node sees revocations immediately on cold cache. + * + *

All operations are idempotent: revoking an already-revoked visitor is a + * no-op (the row's {@code revokedAt} is updated for record-keeping); + * un-revoking an un-revoked one is also a no-op. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WebChatTokenRevocationService { + + static final Duration CACHE_TTL = Duration.ofMinutes(5); + private static final int CACHE_MAX_SIZE = 10_000; + + private final WebChatRevokedVisitorMapper revokedVisitorMapper; + + /** + * Keyed by "{channelId}:{visitorId}". Value is true when an active + * revocation row exists, false otherwise. absence means "not cached"; + * callers must treat null as "fall through to DB". + */ + private final Cache revocationCache = Caffeine.newBuilder() + .expireAfterWrite(CACHE_TTL) + .maximumSize(CACHE_MAX_SIZE) + .build(); + + /** + * True if this visitor is currently revoked on this channel. Pads the + * cache miss with a single DB lookup; the result is then cached for + * {@link #CACHE_TTL}. + */ + public boolean isRevoked(Long channelId, String visitorId) { + if (channelId == null || visitorId == null) { + return false; + } + String key = channelId + ":" + visitorId; + Boolean cached = revocationCache.getIfPresent(key); + if (cached != null) { + return cached; + } + boolean revoked = lookupRevoked(channelId, visitorId); + revocationCache.put(key, revoked); + return revoked; + } + + /** + * Record a revocation. Idempotent: re-revoking an already-revoked visitor + * refreshes {@code revokedAt} + {@code reason} on the existing row. + * Flushes the cache so the change is visible immediately on this node. + */ + public void revoke(Long channelId, String visitorId, String reason) { + WebChatRevokedVisitorEntity existing = findActive(channelId, visitorId); + LocalDateTime now = LocalDateTime.now(); + if (existing == null) { + WebChatRevokedVisitorEntity row = new WebChatRevokedVisitorEntity(); + row.setChannelId(channelId); + row.setVisitorId(visitorId); + row.setReason(reason); + row.setRevokedAt(now); + row.setCreateTime(now); + row.setUpdateTime(now); + row.setDeleted(0); + revokedVisitorMapper.insert(row); + } else { + existing.setReason(reason); + existing.setRevokedAt(now); + existing.setUpdateTime(now); + revokedVisitorMapper.updateById(existing); + } + revocationCache.put(channelId + ":" + visitorId, true); + log.info("[WebChat] visitor revoked: channelId={}, visitorId={}, reason={}", + channelId, visitorId, reason); + } + + /** + * Lift a revocation. Idempotent. Removes the cache entry so subsequent + * {@link #isRevoked} calls re-query the DB (and find nothing). + */ + public void unrevoke(Long channelId, String visitorId) { + WebChatRevokedVisitorEntity existing = findActive(channelId, visitorId); + if (existing != null) { + existing.setDeleted(1); + existing.setUpdateTime(LocalDateTime.now()); + revokedVisitorMapper.updateById(existing); + } + // Invalidate rather than put(false): the un-revoke may race with a + // concurrent revoke on another node. Forcing a DB re-lookup is safer. + revocationCache.invalidate(channelId + ":" + visitorId); + log.info("[WebChat] visitor un-revoked: channelId={}, visitorId={}", + channelId, visitorId); + } + + private boolean lookupRevoked(Long channelId, String visitorId) { + return findActive(channelId, visitorId) != null; + } + + private WebChatRevokedVisitorEntity findActive(Long channelId, String visitorId) { + return revokedVisitorMapper.selectOne(new LambdaQueryWrapper() + .eq(WebChatRevokedVisitorEntity::getChannelId, channelId) + .eq(WebChatRevokedVisitorEntity::getVisitorId, visitorId) + .eq(WebChatRevokedVisitorEntity::getDeleted, 0) + .last("LIMIT 1")); + } + + /** Test-only: drop every cached entry so the next isRevoked() falls through to DB. */ + void invalidateCacheForTest() { + revocationCache.invalidateAll(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/repository/WebChatRevokedVisitorMapper.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/repository/WebChatRevokedVisitorMapper.java new file mode 100644 index 00000000..90e3e3cb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/repository/WebChatRevokedVisitorMapper.java @@ -0,0 +1,13 @@ +package vip.mate.channel.webchat.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.channel.webchat.WebChatRevokedVisitorEntity; + +/** + * Mapper for {@link WebChatRevokedVisitorEntity}. Lives under {@code repository} + * so the application-wide {@code @MapperScan("vip.mate.**.repository")} picks it up. + */ +@Mapper +public interface WebChatRevokedVisitorMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java new file mode 100644 index 00000000..88a531e9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java @@ -0,0 +1,192 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.channel.webchat.WebChatController.WebChatSessionView; +import vip.mate.common.result.R; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of visitor-token revocation + expiration (epic #355 PR 2): + *

+ */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_revoke_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatTokenRevocationTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_401L; + private static final long AGENT_ID = 9_147_4011L; + + @Autowired private WebChatController controller; + @Autowired private WebChatAdminController adminController; + @Autowired private WebChatTokenRevocationService revocationService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update("DELETE FROM webchat_revoked_visitor WHERE channel_id = ?", CHANNEL_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-revoke-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + // Invalidate any cached revocation state from earlier tests sharing this Spring context. + revocationService.invalidateCacheForTest(); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("revoked visitor: token verify flips to false, /sessions returns 401") + void revokedVisitorCannotReachManagementEndpoints() { + controller.createSession(API_KEY, req("vRevoke", "s1")); + String token = tokenFor("vRevoke"); + + // Pre-revoke: endpoint works. + R ok = controller.listSessions(API_KEY, token, "vRevoke", false); + assertThat(ok.getCode()).isEqualTo(200); + + revocationService.revoke(CHANNEL_ID, "vRevoke", "abuse"); + + R denied = controller.listSessions(API_KEY, token, "vRevoke", false); + assertThat(denied.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("un-revoke: token verify flips back to true, /sessions works again") + void unrevokeRestoresAccess() { + controller.createSession(API_KEY, req("vUnrev", "s1")); + String token = tokenFor("vUnrev"); + + revocationService.revoke(CHANNEL_ID, "vUnrev", "test"); + assertThat(controller.listSessions(API_KEY, token, "vUnrev", false).getCode()).isEqualTo(401); + + revocationService.unrevoke(CHANNEL_ID, "vUnrev"); + R ok = controller.listSessions(API_KEY, token, "vUnrev", false); + assertThat(ok.getCode()).isEqualTo(200); + } + + @Test + @DisplayName("revoke is idempotent (single row, no errors on double-revoke)") + void revokeIsIdempotent() { + revocationService.revoke(CHANNEL_ID, "vIdem", "first"); + revocationService.revoke(CHANNEL_ID, "vIdem", "second"); + + Integer rows = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ? AND deleted = 0", + Integer.class, CHANNEL_ID, "vIdem"); + assertThat(rows).isEqualTo(1); + } + + @Test + @DisplayName("cache: revoking twice + isRevoked yields exactly one persisted row") + void revokePersistsSingleRowAcrossMultipleCalls() { + // The cache makes revoke() + immediate isRevoked() cheap, but the source of + // truth is the DB — assert the table still contains exactly one row even + // after the visitor is queried multiple times post-revoke. + revocationService.revoke(CHANNEL_ID, "vCache", "x"); + revocationService.isRevoked(CHANNEL_ID, "vCache"); + revocationService.isRevoked(CHANNEL_ID, "vCache"); + revocationService.isRevoked(CHANNEL_ID, "vCache"); + + Integer rows = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ?", + Integer.class, CHANNEL_ID, "vCache"); + assertThat(rows).isEqualTo(1); + } + + @Test + @DisplayName("expired token is rejected even when visitor is not revoked") + void expiredTokenRejected() { + // Mint a token that already expired a minute ago. + long past = java.time.Instant.now().getEpochSecond() - 60; + String expired = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "vExp", past); + + controller.createSession(API_KEY, req("vExp", "s1")); + R r = controller.listSessions(API_KEY, expired, "vExp", false); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("/stream is unaffected by revocation — a revoked visitor can still start fresh") + void streamUnaffectedByRevocation() { + // We can't actually exercise /stream without a real LLM, but we can verify + // the contract: revoking a visitor leaves verifyVisitorTokenSignature() (which + // /stream never calls anyway) intact. The point of this test is to lock in + // that the revocation check lives in the instance verifyVisitorToken, not in + // the static signature check. + revocationService.revoke(CHANNEL_ID, "vStream", "test"); + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "vStream"); + // Signature still verifies (proves /stream's "mint a fresh token" path works): + assertThat(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL_ID, "vStream", token)) + .isTrue(); + } + + @Test + @DisplayName("admin endpoint POST /revoked-visitor records the revocation + audit") + void adminEndpointRevokes() { + WebChatAdminController.RevokeVisitorRequest req = new WebChatAdminController.RevokeVisitorRequest(); + req.setChannelId(CHANNEL_ID); + req.setVisitorId("vAdmin"); + req.setReason("admin-test"); + + R r = adminController.revokeVisitor(req, null); + assertThat(r.getCode()).isEqualTo(200); + + Integer rows = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ? AND deleted = 0", + Integer.class, CHANNEL_ID, "vAdmin"); + assertThat(rows).isEqualTo(1); + + Integer audit = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_audit_event WHERE action = ? AND resource_id = ?", + Integer.class, "webchat.revoke-visitor", String.valueOf(CHANNEL_ID)); + assertThat(audit).isGreaterThan(0); + } +} 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 index 96ce3154..1ea13da2 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java @@ -2,90 +2,122 @@ package vip.mate.channel.webchat; import org.junit.jupiter.api.Test; +import java.time.Instant; + import static org.junit.jupiter.api.Assertions.*; /** * PR #297 P1 IDOR 修复回归测试:list/messages/delete 端点的鉴权不能再只靠调用方自报的 visitorId, * 必须验证服务端用密钥签发的 visitor token。这里覆盖 token 的签发/校验语义。 + *

注:V148 之后 token 形态变成 {@code .},exp 参与签名; + * 撤销表查询由实例 {@link WebChatController#verifyVisitorToken} 接入,签名 + 过期 + * 部分通过 {@link WebChatController#verifyVisitorTokenSignature} static 暴露给单测。 */ 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"; + private static final long FAR_FUTURE = Instant.now().getEpochSecond() + 3_600L; // ==================== 签发 ==================== @Test void token_isDeterministic_forSameInputs() { assertEquals( - WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR), - WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR)); + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE)); } @Test void token_differsPerVisitor() { assertNotEquals( - WebChatController.computeVisitorToken(SECRET, CHANNEL, "alice"), - WebChatController.computeVisitorToken(SECRET, CHANNEL, "bob")); + WebChatController.computeVisitorToken(SECRET, CHANNEL, "alice", FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, CHANNEL, "bob", FAR_FUTURE)); } @Test void token_isChannelBound_notPortable() { // 同一 visitorId 在不同渠道下 token 不同 → 持 A 渠道 token 不能操作 B 渠道同名 visitor。 assertNotEquals( - WebChatController.computeVisitorToken(SECRET, 1L, VISITOR), - WebChatController.computeVisitorToken(SECRET, 2L, VISITOR)); + WebChatController.computeVisitorToken(SECRET, 1L, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, 2L, VISITOR, FAR_FUTURE)); } @Test void token_dependsOnSecret() { assertNotEquals( - WebChatController.computeVisitorToken("secret-a", CHANNEL, VISITOR), - WebChatController.computeVisitorToken("secret-b", CHANNEL, VISITOR)); + WebChatController.computeVisitorToken("secret-a", CHANNEL, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken("secret-b", CHANNEL, VISITOR, FAR_FUTURE)); } - // ==================== 校验 ==================== + @Test + void token_differsWhenExpirationDiffers() { + // Two tokens for the same (channel, visitor) but different exp must differ — + // otherwise a leaked old token could be replayed past its expiry. + assertNotEquals( + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE + 60)); + } + + // ==================== 校验(签名 + 过期) ==================== @Test void verify_acceptsTokenIssuedForSameVisitor() { - String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR); - assertTrue(WebChatController.verifyVisitorToken(SECRET, CHANNEL, VISITOR, token)); + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); + assertTrue(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, token)); } @Test void verify_rejectsForgedVisitorIdWithoutToken() { // 攻击者持公开 key,传受害者 visitorId,但拿不到对应 token。 - assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", null)); - assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", "")); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", null)); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", "")); } @Test void verify_rejectsTokenMintedForAnotherVisitor() { // 攻击者拿自己 visitor 的合法 token,去操作受害者 visitor → 必须失败。 - String attackerToken = WebChatController.computeVisitorToken(SECRET, CHANNEL, "attacker"); - assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", attackerToken)); + String attackerToken = WebChatController.computeVisitorToken(SECRET, CHANNEL, "attacker", FAR_FUTURE); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", attackerToken)); } @Test void verify_rejectsTokenFromAnotherChannel() { - String tokenForChannel1 = WebChatController.computeVisitorToken(SECRET, 1L, VISITOR); - assertFalse(WebChatController.verifyVisitorToken(SECRET, 2L, VISITOR, tokenForChannel1)); + String tokenForChannel1 = WebChatController.computeVisitorToken(SECRET, 1L, VISITOR, FAR_FUTURE); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, 2L, VISITOR, tokenForChannel1)); } @Test void verify_rejectsTamperedToken() { - String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR); + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); String tampered = token.substring(0, token.length() - 1) + (token.endsWith("A") ? "B" : "A"); - assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, VISITOR, tampered)); + assertFalse(WebChatController.verifyVisitorTokenSignature(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)); + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, null, VISITOR, token)); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, null, token)); + } + + @Test + void verify_rejectsExpiredToken() { + long past = Instant.now().getEpochSecond() - 60; + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, past); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, token)); + } + + @Test + void verify_rejectsTamperedExpiration() { + // Attacker takes a valid token and bumps the exp — but exp participates in + // the HMAC, so the signature no longer matches. + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); + int dot = token.lastIndexOf('.'); + String tampered = token.substring(0, dot + 1) + (FAR_FUTURE + 3_600); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, tampered)); } // ============ conversationId / username 边界(避免溢出 VARCHAR(64) → /stream 500)============