package com.wok.supportbot.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; import java.util.Set; import java.util.stream.Collectors; /** * SDK 专用 JWT 令牌提供者 * 独立于管理后台 JwtTokenProvider,使用独立密钥签发和验证 SDK Token。 *

* Token 中包含: * - sub: apiKeyId(API Key 标识) * - ak: 脱敏后的 API Key * - rids: 允许使用的客服角色 ID 列表 */ @Slf4j @Component public class SdkJwtTokenProvider { private final SecretKey key; private final long defaultExpiration; /** 24 小时上限 */ private static final long MAX_EXPIRATION = 86400000L; /** 5 分钟下限 */ private static final long MIN_EXPIRATION = 300000L; /** 默认密钥标识(禁止使用) */ private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change"; public SdkJwtTokenProvider( @Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret, @Value("${jwt.sdk-expiration:7200000}") long defaultExpiration) { if (DEFAULT_SECRET.equals(sdkSecret)) { log.warn("⚠️ SDK JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.sdk-secret 为强随机字符串"); } this.key = Keys.hmacShaKeyFor(sdkSecret.getBytes(StandardCharsets.UTF_8)); this.defaultExpiration = defaultExpiration; } /** * 签发 SDK JWT Token * * @param apiKeyId API Key 标识 * @param maskedApiKey 脱敏后的 API Key * @param roleIds 允许使用的客服角色 ID 列表 * @param expirationMs 过期时间(毫秒),会被钳制到 [5min, 24h] * @return JWT Token 字符串 */ public String generateToken(String apiKeyId, String maskedApiKey, List roleIds, long expirationMs) { long clampedExpiration = clampExpiration(expirationMs); Date now = new Date(); return Jwts.builder() .subject(apiKeyId) .claim("ak", maskedApiKey) .claim("rids", roleIds) .issuedAt(now) .expiration(new Date(now.getTime() + clampedExpiration)) .signWith(key) .compact(); } /** * 使用默认过期时间签发 SDK JWT Token */ public String generateToken(String apiKeyId, String maskedApiKey, List roleIds) { return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration); } /** * 验证并解析 Token,返回 Claims * * @param token JWT Token 字符串 * @return Claims 对象 * @throws ExpiredJwtException Token 已过期 * @throws JwtException Token 签名无效或格式错误 */ public Claims parseToken(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); } /** * 验证 Token 是否有效(签名正确且未过期) */ public boolean validateToken(String token) { try { parseToken(token); return true; } catch (Exception e) { return false; } } /** * 从 Claims 中提取 apiKeyId(subject) */ public String getApiKeyId(Claims claims) { return claims.getSubject(); } /** * 从 Claims 中提取允许的角色 ID 集合 * JWT 中 rids 为 List<Integer>,需转换为 Set<Long> */ @SuppressWarnings("unchecked") public Set getAllowedRoleIds(Claims claims) { List rids = claims.get("rids", List.class); if (rids == null) { return Set.of(); } return rids.stream() .map(r -> ((Number) r).longValue()) .collect(Collectors.toSet()); } /** * 脱敏 API Key: sk_ab****xyz0 * 保留前 4 位和后 4 位,中间用 **** 替代 */ public static String maskApiKey(String keyValue) { if (keyValue == null || keyValue.length() < 12) { return "****"; } return keyValue.substring(0, 4) + "****" + keyValue.substring(keyValue.length() - 4); } /** * 将过期时间钳制到 [5min, 24h] 范围内 */ private long clampExpiration(long expirationMs) { return Math.max(MIN_EXPIRATION, Math.min(expirationMs, MAX_EXPIRATION)); } }