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...

相关推荐
摇滚侠9 小时前
Spring Boot 3零基础教程,IOC容器中组件的注册,笔记08
spring boot·笔记·后端
程序员小凯11 小时前
Spring Boot测试框架详解
java·spring boot·后端
你的人类朋友12 小时前
什么是断言?
前端·后端·安全
程序员小凯13 小时前
Spring Boot缓存机制详解
spring boot·后端·缓存
i学长的猫14 小时前
Ruby on Rails 从0 开始入门到进阶到高级 - 10分钟速通版
后端·ruby on rails·ruby
用户214118326360214 小时前
别再为 Claude 付费!Codex + 免费模型 + cc-switch,多场景 AI 编程全搞定
后端
茯苓gao14 小时前
Django网站开发记录(一)配置Mniconda,Python虚拟环境,配置Django
后端·python·django
Cherry Zack14 小时前
Django视图进阶:快捷函数、装饰器与请求响应
后端·python·django
爱读源码的大都督15 小时前
为什么有了HTTP,还需要gPRC?
java·后端·架构