java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot JWT动态密钥轮换

SpringBoot实现JWT动态密钥轮换的示例详解

作者:风象南

这篇文章主要为大家详细介绍了SpringBoot实现JWT动态密钥轮换的相关知识,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下

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

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

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

这其实非常危险:

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

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

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

我们的目标是实现:

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

签名实现:HMAC vs RSA

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

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

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

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

这意味着:

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

没有信任隔离

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

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

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

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

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-20251013-956"
}

这样,验证方只需要:

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

核心实现

技术架构

后端技术栈:

前端技术栈:

核心组件设计

1.DynamicKeyStore - 动态密钥存储管理器

@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 生成(使用当前活跃密钥):

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 验证(支持多版本密钥):

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 - 定时轮换调度器

@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接口

认证相关:

管理功能:

演示功能:

5.前端交互界面

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

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

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

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

Token解析:JWT结构分析工具

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

平滑过渡策略

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

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

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

关键验证点

总结

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

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

到此这篇关于SpringBoot实现JWT动态密钥轮换的示例详解的文章就介绍到这了,更多相关SpringBoot JWT动态密钥轮换内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文