SpringBoot實(shí)現(xiàn)JWT動(dòng)態(tài)密鑰輪換的示例詳解
背景:為什么 JWT 密鑰也要"輪換"
JWT(JSON Web Token) 是當(dāng)代認(rèn)證體系的常用方案, 無(wú)論是單體系統(tǒng)、微服務(wù)、還是前后端分離登錄,幾乎都會(huì)用到它。
但在大多數(shù)系統(tǒng)里,簽名密鑰往往是一成不變的—— 一旦生成,常年不換,代碼里寫死或放在配置文件中。
這其實(shí)非常危險(xiǎn):
- 一旦密鑰被誤傳或泄露,攻擊者就能偽造任意用戶的合法 Token
- 無(wú)論是測(cè)試環(huán)境誤配置,還是日志誤打出 key,都可能導(dǎo)致密鑰泄露,帶來(lái)安全隱患
于是我們面臨一個(gè)工程問(wèn)題:
"如何能動(dòng)態(tài)更新 JWT 簽名密鑰,且不讓用戶重新登錄?"
目標(biāo):密鑰可定期更新,但不影響登錄狀態(tài)
我們的目標(biāo)是實(shí)現(xiàn):
| 時(shí)間點(diǎn) | 動(dòng)作 | 用戶狀態(tài) |
|---|---|---|
| 10月1日 | 使用 keypair_A 生成 JWT | 正常 |
| 10月10日 | 上線 keypair_B,新簽發(fā)用它 | 老 Token 仍有效 |
| 10月20日 | 老 Token 全部過(guò)期 | 刪除 keypair_A |
- 老 Token 正??沈?yàn)簽
- 新 Token 自動(dòng)使用新密鑰
- 用戶無(wú)感知,不掉線
簽名實(shí)現(xiàn):HMAC vs RSA
JWT 支持多種簽名算法,常見(jiàn)的有兩種:
| 類型 | 算法示例 | 是否對(duì)稱 | 特點(diǎn) |
|---|---|---|---|
| HMAC(對(duì)稱) | HS256 / HS512 | ? 是 | 簽發(fā)方與驗(yàn)證方共用同一密鑰 |
| RSA / ECDSA(非對(duì)稱) | RS256 / ES256 | ? 否 | 簽發(fā)方用私鑰簽名,驗(yàn)證方用公鑰驗(yàn)簽 |
很多系統(tǒng)為了圖省事,默認(rèn)使用 HMAC(例如 HS256)。 它確實(shí)簡(jiǎn)單,但存在一個(gè)致命問(wèn)題:
一旦 HMAC 密鑰泄露,攻擊者可以偽造任何合法 Token。
這意味著:
簽發(fā)方 = 驗(yàn)證方 = 攻擊方(如果密鑰泄露)
沒(méi)有信任隔離
無(wú)法安全輪換:新舊密鑰都得讓驗(yàn)證邏輯同時(shí)持有
這也是為什么更高安全等級(jí)的系統(tǒng)都改用 RSA / ECDSA 非對(duì)稱簽名。
安全輪換的關(guān)鍵:KID(Key ID)+ 多版本密鑰倉(cāng)庫(kù)
JWT Header 允許帶一個(gè) "kid" 字段,用來(lái)標(biāo)識(shí)當(dāng)前簽名使用的密鑰版本。 比如:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-20251013-956"
}
這樣,驗(yàn)證方只需要:
- 讀取 header.kid
- 去 KeyStore 找對(duì)應(yīng)公鑰
- 使用它來(lái)驗(yàn)簽
老 Token 用老公鑰,新 Token 用新公鑰,完美共存。
核心實(shí)現(xiàn)
技術(shù)架構(gòu)
后端技術(shù)棧:
- Spring Boot 3 + Spring Scheduling
- JJWT 0.12.3(JWT 處理庫(kù))
- RSA 2048 非對(duì)稱加密
- 內(nèi)存 ConcurrentHashMap 存儲(chǔ)(方便快速體驗(yàn)DEMO)
前端技術(shù)棧:
- HTML5 + CSS3 + JavaScript ES6
- Tailwind CSS UI 框架
- 前后端分離
核心組件設(shè)計(jì)
1.DynamicKeyStore - 動(dòng)態(tài)密鑰存儲(chǔ)管理器
@Service
public class DynamicKeyStore {
// 線程安全的密鑰存儲(chǔ)
private final Map<String, KeyInfo> keyStore = new ConcurrentHashMap<>();
private volatile String currentKeyId;
// 生成新密鑰對(duì)
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);
// 輪換邏輯:舊密鑰標(biāo)記為非活躍,新密鑰設(shè)為當(dāng)前
if (currentKeyId != null) {
keyStore.get(currentKeyId).setActive(false);
}
currentKeyId = keyId;
keyStore.put(keyId, keyInfo);
return keyId;
}
// 根據(jù)KID獲取密鑰(支持多版本共存)
public KeyInfo getKey(String keyId) {
return keyStore.get(keyId);
}
}
2.JwtTokenService - JWT 服務(wù)層
Token 生成(使用當(dāng)前活躍密鑰):
public String generateToken(String username, Map<String, Object> claims) {
// 獲取當(dāng)前活躍密鑰
var currentKey = keyStore.getCurrentKey();
String keyId = currentKey.getKeyId();
// 構(gòu)建JWT,設(shè)置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 驗(yàn)證(支持多版本密鑰):
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. 根據(jù)KID獲取對(duì)應(yīng)公鑰
var keyInfo = keyStore.getKey(keyId);
if (keyInfo == null) {
throw new JwtException("找不到對(duì)應(yīng)的密鑰: " + keyId);
}
PublicKey publicKey = keyInfo.getKeyPair().getPublic();
// 3. 使用公鑰驗(yàn)證Token
Jws<Claims> jws = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token);
return jws.getPayload();
}
3.KeyRotationScheduler - 定時(shí)輪換調(diào)度器
@Component
public class KeyRotationScheduler {
@Value("${jwt.rotation-period-days:7}")
private int rotationPeriodDays;
@Value("${jwt.grace-period-days:14}")
private int gracePeriodDays;
// 應(yīng)用啟動(dòng)時(shí)初始化
@EventListener(ApplicationReadyEvent.class)
public void initialize() {
keyStore.initialize();
}
// 定時(shí)輪換:每天凌晨2點(diǎn)檢查
@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);
}
}
// 定時(shí)清理:每天凌晨3點(diǎn)清理過(guò)期密鑰
@Scheduled(cron = "0 0 3 * * ?")
public void scheduledKeyCleanup() {
List<String> removedKeys = keyStore.cleanupExpiredKeys(gracePeriodDays);
if (!removedKeys.isEmpty()) {
logger.info("清理了 {} 個(gè)過(guò)期密鑰", removedKeys.size());
}
}
}
4.API接口
認(rèn)證相關(guān):
POST /api/auth/login- 用戶登錄POST /api/auth/validate- Token驗(yàn)證POST /api/auth/refresh- Token刷新GET /api/auth/me- 獲取當(dāng)前用戶信息
管理功能:
POST /api/auth/admin/rotate-keys- 手動(dòng)輪換密鑰POST /api/auth/admin/cleanup-keys- 清理過(guò)期密鑰
演示功能:
GET /api/demo/key-stats- 獲取密鑰統(tǒng)計(jì)POST /api/demo/parse-token- 解析TokenPOST /api/demo/generate-test-token- 生成測(cè)試TokenGET /api/demo/protected- 受保護(hù)資源
5.前端交互界面
DEMO提供了完整的前后端分離演示界面
用戶登錄:登錄認(rèn)證和狀態(tài)顯示
受保護(hù)資源:演示Token保護(hù)機(jī)制
密鑰信息:實(shí)時(shí)密鑰存儲(chǔ)狀態(tài)監(jiān)控
Token解析:JWT結(jié)構(gòu)分析工具
管理功能:手動(dòng)密鑰輪換和清理
平滑過(guò)渡策略
密鑰輪換不是"替換",而是"共存"。
| 階段 | 動(dòng)作 | 狀態(tài) |
|---|---|---|
| ① 新密鑰上線 | 新 Token 用新 Key 簽發(fā) | 雙密鑰并行 |
| ② 老 Token 仍驗(yàn)證通過(guò) | 舊 Key 在驗(yàn)證端保留 | 用戶無(wú)感 |
| ③ 老 Token 過(guò)期 | 刪除舊 Key | 安全收尾 |
整個(gè)過(guò)程無(wú)須人工干預(yù),也不需要讓用戶重新登錄。
關(guān)鍵驗(yàn)證點(diǎn)
- 新Token使用新密鑰:輪換后新生成的Token包含新的KID
- 舊Token仍可驗(yàn)證:輪換前的Token繼續(xù)正常使用
- 用戶無(wú)感知:整個(gè)輪換過(guò)程對(duì)用戶完全透明
- 系統(tǒng)監(jiān)控:實(shí)時(shí)查看密鑰狀態(tài)和輪換歷史
總結(jié)
在實(shí)際項(xiàng)目中,密鑰管理往往是被忽視的角落。直到安全審計(jì)時(shí)才發(fā)現(xiàn)問(wèn)題。通過(guò)合理運(yùn)用JWT的KID字段和RSA的非對(duì)稱特性,我們可以讓系統(tǒng)自動(dòng)處理密鑰輪換,而不是事后補(bǔ)救。
從代碼量來(lái)看,增加密鑰輪換功能并不需要大幅改動(dòng)現(xiàn)有架構(gòu),但帶來(lái)的安全收益是長(zhǎng)期的。
到此這篇關(guān)于SpringBoot實(shí)現(xiàn)JWT動(dòng)態(tài)密鑰輪換的示例詳解的文章就介紹到這了,更多相關(guān)SpringBoot JWT動(dòng)態(tài)密鑰輪換內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java實(shí)現(xiàn)優(yōu)先隊(duì)列式廣度優(yōu)先搜索算法的示例代碼
這篇文章主要為大家詳細(xì)介紹了Java如何實(shí)現(xiàn)優(yōu)先隊(duì)列式廣度優(yōu)先搜索算法,文中通過(guò)一個(gè)示例帶大家具體了解了實(shí)現(xiàn)的方法,需要的可以參考一下2022-08-08
淺談spring boot使用thymeleaf版本的問(wèn)題
這篇文章主要介紹了spring boot使用thymeleaf版本的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-08-08
解決SpringBoot多模塊發(fā)布時(shí)99%的問(wèn)題
本文歸納了以下 8 個(gè)原則和發(fā)布時(shí)經(jīng)常出現(xiàn)的 4 個(gè)問(wèn)題的解決方案,掌握了這些原則和解決方案,幾乎可以解決絕大數(shù)SpringBoot發(fā)布問(wèn)題2019-07-07
Maven本地打包war包實(shí)現(xiàn)代碼解析
這篇文章主要介紹了Maven本地打包war包實(shí)現(xiàn)代碼解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-09-09
詳細(xì)聊聊RabbitMQ竟無(wú)法反序列化List問(wèn)題
這篇文章主要給大家介紹了關(guān)于RabbitMQ竟無(wú)法反序列化List的相關(guān)資料,文中通過(guò)示例代碼將問(wèn)題以及解決的過(guò)程介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2021-09-09
java swing實(shí)現(xiàn)電影購(gòu)票系統(tǒng)
這篇文章主要為大家詳細(xì)介紹了java swing實(shí)現(xiàn)電影購(gòu)票系統(tǒng),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-01-01
Springmvc ajax跨域請(qǐng)求處理方法實(shí)例詳解
這篇文章主要介紹了Springmvc ajax跨域請(qǐng)求處理方法實(shí)例詳解,需要的朋友可以參考下2017-10-10
Java+MySQL實(shí)現(xiàn)設(shè)計(jì)優(yōu)惠券系統(tǒng)
這篇文章主要介紹了Java+MySQL實(shí)現(xiàn)設(shè)計(jì)優(yōu)惠券系統(tǒng),文章基于Java與MySQL的相關(guān)資料展開(kāi)詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下2022-05-05
關(guān)于Lists.partition集合分組使用以及注意事項(xiàng)
這篇文章主要介紹了關(guān)于Lists.partition集合分組使用以及注意事項(xiàng),具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-01-01

