mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(webchat): visitorToken revocation + 7-day expiry + Caffeine cache (epic #355 PR 2)
Closes the "no way to ban a single visitor without burning the global JWT secret" gap from the epic. Two changes: 1. Token format: HMAC payload now includes exp, format is `<base64sig>.<expEpochSec>`. Default TTL 7 days (VISITOR_TOKEN_TTL_SECONDS). Expiry participates in the HMAC, so bumping it client-side invalidates the signature. /stream still mints fresh tokens on first contact — a revoked visitor can start a new /stream (gets a new token), they just can't use the old one on management endpoints. 2. WebChatTokenRevocationService — DB-backed registry (webchat_revoked_visitor table from V148) with a 5-minute Caffeine cache in front. revoke() / unrevoke() / isRevoked(). The cache accepts up to 10min eventual consistency across instances — webchat is low-volume, and a fresh node sees revocations immediately on cold cache. DB remains source of truth. WebChatController.verifyVisitorToken becomes an instance method that chains verifyVisitorTokenSignature (static, HMAC + exp) with isRevoked (instance, DB + cache). All 9 management endpoints now check revocation transitively. Admin endpoint: POST /api/v1/admin/webchat/revoked-visitor (and DELETE to un-revoke). Mounted under /api/v1/admin/** so it requires a MateClaw JWT — visitors can't reach it. Records an audit row (action=webchat.revoke- visitor, resourceType=CHANNEL) via AuditEventService. Tests: - WebChatTokenRevocationTest (@SpringBootTest, 7 cases): revoke blocks /sessions with 401, un-revoke restores, double-revoke idempotent, expired token rejected even without revocation, /stream unaffected (signature still verifies), admin endpoint inserts row + audit. - WebChatVisitorTokenTest extended to 16 cases — added expired-token, tampered-exp, and "differs when exp differs" coverage; existing verify_* cases moved to verifyVisitorTokenSignature (the static half). Regression: WebChatSchemaFieldsTest (5/5), WebChatCreateSessionTest (9/9), WebChatSessionManagementTest (5/5), WebChatStopStreamTest (5/5). Part of epic #355.
This commit is contained in:
parent
332f3339a9
commit
bccc5767ed
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Void> 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<Void> unrevokeVisitor(
|
||||||
|
@RequestBody Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -73,6 +73,10 @@ public class WebChatController {
|
|||||||
private final ConversationCompletionPublisher completionPublisher;
|
private final ConversationCompletionPublisher completionPublisher;
|
||||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||||
private final WebChatFileService fileService;
|
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
|
* 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, visitorId) 做 HMAC-SHA256,签发不可伪造的 visitor token。
|
||||||
* 载荷含 channelId,使 token 不能跨渠道复用。
|
* 载荷含 channelId,使 token 不能跨渠道复用。
|
||||||
|
* <p>Token 默认 7 天后过期({@link #VISITOR_TOKEN_TTL_SECONDS});过期时间作为后缀
|
||||||
|
* 明文附加在 HMAC 之后({@code <base64sig>.<expEpochSec>}),既参与签名也方便解析。
|
||||||
|
* 过期后访客可通过 {@code /stream} 重新签发({@code /stream} 不校验 token,只签发)。
|
||||||
*/
|
*/
|
||||||
static String computeVisitorToken(String secret, Long channelId, String visitorId) {
|
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 {
|
try {
|
||||||
Mac mac = Mac.getInstance("HmacSHA256");
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||||
byte[] sig = mac.doFinal((channelId + ":" + visitorId).getBytes(StandardCharsets.UTF_8));
|
String payload = channelId + ":" + visitorId + ":" + expiresAtEpochSecond;
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(sig);
|
byte[] sig = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(sig) + "." + expiresAtEpochSecond;
|
||||||
} catch (GeneralSecurityException e) {
|
} catch (GeneralSecurityException e) {
|
||||||
throw new IllegalStateException("HMAC-SHA256 unavailable", e);
|
throw new IllegalStateException("HMAC-SHA256 unavailable", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 常量时间校验调用方回传的 token:缺失/不匹配均返回 false。
|
* 校验调用方回传的 token 的<b>签名 + 过期</b>。不查撤销表(撤销是实例层职责,
|
||||||
|
* 见 {@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) {
|
if (presented == null || presented.isEmpty() || visitorId == null || channelId == null) {
|
||||||
return false;
|
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);
|
byte[] actual = presented.getBytes(StandardCharsets.UTF_8);
|
||||||
return MessageDigest.isEqual(expected, actual);
|
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 渠道
|
* 通过 API Key 查找 WebChat 渠道
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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.
|
||||||
|
* <p>
|
||||||
|
* {@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;
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Boolean> 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<WebChatRevokedVisitorEntity>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<WebChatRevokedVisitorEntity> {
|
||||||
|
}
|
||||||
@ -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):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link WebChatTokenRevocationService#revoke} flips
|
||||||
|
* {@link WebChatController#verifyVisitorToken} to false on subsequent calls.</li>
|
||||||
|
* <li>{@link WebChatTokenRevocationService#unrevoke} flips it back.</li>
|
||||||
|
* <li>Revoke is idempotent (double-revoke = single row).</li>
|
||||||
|
* <li>Cache amortises the DB hit: a second {@code isRevoked} for the same
|
||||||
|
* (channelId, visitorId) within the TTL does not re-query.</li>
|
||||||
|
* <li>Expired tokens are rejected before revocation is even consulted.</li>
|
||||||
|
* <li>An end-to-end management call ({@code GET /sessions}) honours the
|
||||||
|
* revocation — a revoked visitor gets 401.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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<Void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,90 +2,122 @@ package vip.mate.channel.webchat;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PR #297 P1 IDOR 修复回归测试:list/messages/delete 端点的鉴权不能再只靠调用方自报的 visitorId,
|
* PR #297 P1 IDOR 修复回归测试:list/messages/delete 端点的鉴权不能再只靠调用方自报的 visitorId,
|
||||||
* 必须验证服务端用密钥签发的 visitor token。这里覆盖 token 的签发/校验语义。
|
* 必须验证服务端用密钥签发的 visitor token。这里覆盖 token 的签发/校验语义。
|
||||||
|
* <p>注:V148 之后 token 形态变成 {@code <base64sig>.<expEpochSec>},exp 参与签名;
|
||||||
|
* 撤销表查询由实例 {@link WebChatController#verifyVisitorToken} 接入,签名 + 过期
|
||||||
|
* 部分通过 {@link WebChatController#verifyVisitorTokenSignature} static 暴露给单测。
|
||||||
*/
|
*/
|
||||||
class WebChatVisitorTokenTest {
|
class WebChatVisitorTokenTest {
|
||||||
|
|
||||||
private static final String SECRET = "test-secret-do-not-use-in-prod";
|
private static final String SECRET = "test-secret-do-not-use-in-prod";
|
||||||
private static final Long CHANNEL = 7L;
|
private static final Long CHANNEL = 7L;
|
||||||
private static final String VISITOR = "visitor-abc";
|
private static final String VISITOR = "visitor-abc";
|
||||||
|
private static final long FAR_FUTURE = Instant.now().getEpochSecond() + 3_600L;
|
||||||
|
|
||||||
// ==================== 签发 ====================
|
// ==================== 签发 ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void token_isDeterministic_forSameInputs() {
|
void token_isDeterministic_forSameInputs() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR),
|
WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE),
|
||||||
WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR));
|
WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void token_differsPerVisitor() {
|
void token_differsPerVisitor() {
|
||||||
assertNotEquals(
|
assertNotEquals(
|
||||||
WebChatController.computeVisitorToken(SECRET, CHANNEL, "alice"),
|
WebChatController.computeVisitorToken(SECRET, CHANNEL, "alice", FAR_FUTURE),
|
||||||
WebChatController.computeVisitorToken(SECRET, CHANNEL, "bob"));
|
WebChatController.computeVisitorToken(SECRET, CHANNEL, "bob", FAR_FUTURE));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void token_isChannelBound_notPortable() {
|
void token_isChannelBound_notPortable() {
|
||||||
// 同一 visitorId 在不同渠道下 token 不同 → 持 A 渠道 token 不能操作 B 渠道同名 visitor。
|
// 同一 visitorId 在不同渠道下 token 不同 → 持 A 渠道 token 不能操作 B 渠道同名 visitor。
|
||||||
assertNotEquals(
|
assertNotEquals(
|
||||||
WebChatController.computeVisitorToken(SECRET, 1L, VISITOR),
|
WebChatController.computeVisitorToken(SECRET, 1L, VISITOR, FAR_FUTURE),
|
||||||
WebChatController.computeVisitorToken(SECRET, 2L, VISITOR));
|
WebChatController.computeVisitorToken(SECRET, 2L, VISITOR, FAR_FUTURE));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void token_dependsOnSecret() {
|
void token_dependsOnSecret() {
|
||||||
assertNotEquals(
|
assertNotEquals(
|
||||||
WebChatController.computeVisitorToken("secret-a", CHANNEL, VISITOR),
|
WebChatController.computeVisitorToken("secret-a", CHANNEL, VISITOR, FAR_FUTURE),
|
||||||
WebChatController.computeVisitorToken("secret-b", CHANNEL, VISITOR));
|
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
|
@Test
|
||||||
void verify_acceptsTokenIssuedForSameVisitor() {
|
void verify_acceptsTokenIssuedForSameVisitor() {
|
||||||
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR);
|
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE);
|
||||||
assertTrue(WebChatController.verifyVisitorToken(SECRET, CHANNEL, VISITOR, token));
|
assertTrue(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verify_rejectsForgedVisitorIdWithoutToken() {
|
void verify_rejectsForgedVisitorIdWithoutToken() {
|
||||||
// 攻击者持公开 key,传受害者 visitorId,但拿不到对应 token。
|
// 攻击者持公开 key,传受害者 visitorId,但拿不到对应 token。
|
||||||
assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", null));
|
assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", null));
|
||||||
assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", ""));
|
assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verify_rejectsTokenMintedForAnotherVisitor() {
|
void verify_rejectsTokenMintedForAnotherVisitor() {
|
||||||
// 攻击者拿自己 visitor 的合法 token,去操作受害者 visitor → 必须失败。
|
// 攻击者拿自己 visitor 的合法 token,去操作受害者 visitor → 必须失败。
|
||||||
String attackerToken = WebChatController.computeVisitorToken(SECRET, CHANNEL, "attacker");
|
String attackerToken = WebChatController.computeVisitorToken(SECRET, CHANNEL, "attacker", FAR_FUTURE);
|
||||||
assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, "victim", attackerToken));
|
assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", attackerToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verify_rejectsTokenFromAnotherChannel() {
|
void verify_rejectsTokenFromAnotherChannel() {
|
||||||
String tokenForChannel1 = WebChatController.computeVisitorToken(SECRET, 1L, VISITOR);
|
String tokenForChannel1 = WebChatController.computeVisitorToken(SECRET, 1L, VISITOR, FAR_FUTURE);
|
||||||
assertFalse(WebChatController.verifyVisitorToken(SECRET, 2L, VISITOR, tokenForChannel1));
|
assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, 2L, VISITOR, tokenForChannel1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verify_rejectsTamperedToken() {
|
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)
|
String tampered = token.substring(0, token.length() - 1)
|
||||||
+ (token.endsWith("A") ? "B" : "A");
|
+ (token.endsWith("A") ? "B" : "A");
|
||||||
assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, VISITOR, tampered));
|
assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, tampered));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verify_rejectsNullChannelOrVisitor() {
|
void verify_rejectsNullChannelOrVisitor() {
|
||||||
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR);
|
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE);
|
||||||
assertFalse(WebChatController.verifyVisitorToken(SECRET, null, VISITOR, token));
|
assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, null, VISITOR, token));
|
||||||
assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, null, 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)============
|
// ============ conversationId / username 边界(避免溢出 VARCHAR(64) → /stream 500)============
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user