You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
149 lines
4.8 KiB
149 lines
4.8 KiB
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。
|
|
* <p>
|
|
* 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<Long> 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<Long> 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<Long> 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));
|
|
}
|
|
}
|