SpringBoot实现JWT动态密钥轮换

背景:为什么 JWT 密钥也要"轮换"

JWT(JSON Web Token) 是当代认证体系的常用方案, 无论是单体系统、微服务、还是前后端分离登录,几乎都会用到它。

但在大多数系统里,签名密钥往往是一成不变的------ 一旦生成,常年不换,代码里写死或放在配置文件中。

这其实非常危险:

  • 一旦密钥被误传或泄露,攻击者就能伪造任意用户的合法 Token
  • 无论是测试环境误配置,还是日志误打出 key,都可能导致密钥泄露,带来安全隐患

于是我们面临一个工程问题:

"如何能动态更新 JWT 签名密钥,且不让用户重新登录?"

目标:密钥可定期更新,但不影响登录状态

我们的目标是实现:

时间点 动作 用户状态
10月1日 使用 keypair_A 生成 JWT 正常
10月10日 上线 keypair_B,新签发用它 老 Token 仍有效
10月20日 老 Token 全部过期 删除 keypair_A

✅ 老 Token 正常可验签 ✅ 新 Token 自动使用新密钥 ✅ 用户无感知,不掉线

签名实现:HMAC vs RSA

JWT 支持多种签名算法,常见的有两种:

类型 算法示例 是否对称 特点
HMAC(对称) HS256 / HS512 ✅ 是 签发方与验证方共用同一密钥
RSA / ECDSA(非对称) RS256 / ES256 ❌ 否 签发方用私钥签名,验证方用公钥验签

很多系统为了图省事,默认使用 HMAC(例如 HS256)。 它确实简单,但存在一个致命问题:

一旦 HMAC 密钥泄露,攻击者可以伪造任何合法 Token。

这意味着:

签发方 = 验证方 = 攻击方(如果密钥泄露)

没有信任隔离

无法安全轮换:新旧密钥都得让验证逻辑同时持有

这也是为什么更高安全等级的系统都改用 RSA / ECDSA 非对称签名

安全轮换的关键:KID(Key ID)+ 多版本密钥仓库

JWT Header 允许带一个 "kid" 字段,用来标识当前签名使用的密钥版本。 比如:

json 复制代码
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-20251013-956"
}

这样,验证方只需要:

  1. 读取 header.kid
  2. 去 KeyStore 找对应公钥
  3. 使用它来验签

老 Token 用老公钥,新 Token 用新公钥,完美共存。

核心实现

技术架构

后端技术栈:

  • Spring Boot 3 + Spring Scheduling
  • JJWT 0.12.3(JWT 处理库)
  • RSA 2048 非对称加密
  • 内存 ConcurrentHashMap 存储(方便快速体验DEMO)

前端技术栈:

  • HTML5 + CSS3 + JavaScript ES6
  • Tailwind CSS UI 框架
  • 前后端分离

核心组件设计

1️⃣ DynamicKeyStore - 动态密钥存储管理器
java 复制代码
@Service
public class DynamicKeyStore {
    // 线程安全的密钥存储
    private final Map<String, KeyInfo> keyStore = new ConcurrentHashMap<>();
    private volatile String currentKeyId;

    // 生成新密钥对
    public String generateNewKeyPair() {
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
        generator.initialize(2048, new SecureRandom());
        KeyPair keyPair = generator.generateKeyPair();

        String keyId = "key-" + LocalDate.now() + "-" + timestamp;
        KeyInfo keyInfo = new KeyInfo(keyId, keyPair);

        // 轮换逻辑:旧密钥标记为非活跃,新密钥设为当前
        if (currentKeyId != null) {
            keyStore.get(currentKeyId).setActive(false);
        }
        currentKeyId = keyId;
        keyStore.put(keyId, keyInfo);

        return keyId;
    }

    // 根据KID获取密钥(支持多版本共存)
    public KeyInfo getKey(String keyId) {
        return keyStore.get(keyId);
    }
}
2️⃣ JwtTokenService - JWT 服务层

Token 生成(使用当前活跃密钥):

java 复制代码
public String generateToken(String username, Map<String, Object> claims) {
    // 获取当前活跃密钥
    var currentKey = keyStore.getCurrentKey();
    String keyId = currentKey.getKeyId();

    // 构建JWT,设置KID
    JwtBuilder builder = Jwts.builder()
            .subject(username)
            .issuedAt(new Date())
            .expiration(Date.from(Instant.now().plus(24, ChronoUnit.HOURS)))
            .header().keyId(keyId).and()
            .signWith(currentKey.getKeyPair().getPrivate(), Jwts.SIG.RS256);

    // 添加自定义声明
    if (claims != null && !claims.isEmpty()) {
        builder.claims().add(claims);
    }

    return builder.compact();
}

Token 验证(支持多版本密钥):

java 复制代码
public Claims validateToken(String token) throws JwtException {
    // 1. 解析Header获取KID
    String[] parts = token.split("\\.");
    String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]));
    Map<String, Object> headerMap = mapper.readValue(headerJson, Map.class);
    String keyId = (String) headerMap.get("kid");

    if (keyId == null) {
        throw new JwtException("Token缺少密钥ID (kid)");
    }

    // 2. 根据KID获取对应公钥
    var keyInfo = keyStore.getKey(keyId);
    if (keyInfo == null) {
        throw new JwtException("找不到对应的密钥: " + keyId);
    }
    PublicKey publicKey = keyInfo.getKeyPair().getPublic();

    // 3. 使用公钥验证Token
    Jws<Claims> jws = Jwts.parser()
            .verifyWith(publicKey)
            .build()
            .parseSignedClaims(token);

    return jws.getPayload();
}
3️⃣ KeyRotationScheduler - 定时轮换调度器
java 复制代码
@Component
public class KeyRotationScheduler {

    @Value("${jwt.rotation-period-days:7}")
    private int rotationPeriodDays;

    @Value("${jwt.grace-period-days:14}")
    private int gracePeriodDays;

    // 应用启动时初始化
    @EventListener(ApplicationReadyEvent.class)
    public void initialize() {
        keyStore.initialize();
    }

    // 定时轮换:每天凌晨2点检查
    @Scheduled(cron = "0 0 2 * * ?")
    public void scheduledKeyRotation() {
        var currentKey = keyStore.getCurrentKey();
        long daysSinceCreation = ChronoUnit.DAYS.between(
            currentKey.getCreatedAt(), LocalDateTime.now()
        );

        if (daysSinceCreation >= rotationPeriodDays) {
            String newKeyId = keyStore.generateNewKeyPair();
            logger.info("密钥轮换完成: {} -> {}", currentKeyId, newKeyId);
        }
    }

    // 定时清理:每天凌晨3点清理过期密钥
    @Scheduled(cron = "0 0 3 * * ?")
    public void scheduledKeyCleanup() {
        List<String> removedKeys = keyStore.cleanupExpiredKeys(gracePeriodDays);
        if (!removedKeys.isEmpty()) {
            logger.info("清理了 {} 个过期密钥", removedKeys.size());
        }
    }
}

4️⃣ API接口

认证相关:

  • POST /api/auth/login - 用户登录
  • POST /api/auth/validate - Token验证
  • POST /api/auth/refresh - Token刷新
  • GET /api/auth/me - 获取当前用户信息

管理功能:

  • POST /api/auth/admin/rotate-keys - 手动轮换密钥
  • POST /api/auth/admin/cleanup-keys - 清理过期密钥

演示功能:

  • GET /api/demo/key-stats - 获取密钥统计
  • POST /api/demo/parse-token - 解析Token
  • POST /api/demo/generate-test-token - 生成测试Token
  • GET /api/demo/protected - 受保护资源

5️⃣ 前端交互界面

DEMO提供了完整的前后端分离演示界面

用户登录:登录认证和状态显示

受保护资源:演示Token保护机制

密钥信息:实时密钥存储状态监控

Token解析:JWT结构分析工具

管理功能:手动密钥轮换和清理

平滑过渡策略

密钥轮换不是"替换",而是"共存"。

阶段 动作 状态
① 新密钥上线 新 Token 用新 Key 签发 双密钥并行
② 老 Token 仍验证通过 旧 Key 在验证端保留 用户无感
③ 老 Token 过期 删除旧 Key 安全收尾

整个过程无须人工干预,也不需要让用户重新登录。

关键验证点

新Token使用新密钥:轮换后新生成的Token包含新的KID

旧Token仍可验证:轮换前的Token继续正常使用

用户无感知:整个轮换过程对用户完全透明

系统监控:实时查看密钥状态和轮换历史

总结

在实际项目中,密钥管理往往是被忽视的角落。直到安全审计时才发现问题。通过合理运用JWT的KID字段和RSA的非对称特性,我们可以让系统自动处理密钥轮换,而不是事后补救。

从代码量来看,增加密钥轮换功能并不需要大幅改动现有架构,但带来的安全收益是长期的。

github.com/yuboon/java...

相关推荐
小码编匠14 小时前
WPF 动态模拟CPU 使用率曲线图
后端·c#·.net
我是谁的程序员14 小时前
让调试成为团队优势,如何把Charles融入前端与测试的工作流
后端
Java水解14 小时前
Spring AI Alibaba 入门教程:快速集成大模型到Spring Boot应用
后端·spring
Java水解14 小时前
Flowable工作流引擎:Spring Boot集成
后端
王中阳Go背后的男人14 小时前
订单支付后库存不扣减,如何用RabbitMQ来优化?
后端
IT_陈寒14 小时前
Vite 5新特性解析:10个提速技巧让你的开发效率翻倍 🚀
前端·人工智能·后端
yuuki23323314 小时前
【数据结构】单链表的实现
c语言·数据结构·后端
刘一说14 小时前
深入理解 Spring Boot Web 开发中的全局异常统一处理机制
前端·spring boot·后端
塔能物联运维14 小时前
物联网边缘节点数据缓存优化与一致性保障技术
java·后端·物联网·spring·缓存
IT_陈寒15 小时前
Vite 5震撼发布!10个新特性让你的开发效率飙升200% 🚀
前端·人工智能·后端