JWT 的優點是 stateless——伺服器只驗簽章,不查資料庫就能確認身分,但是如果只有單支 token 會遇到一個無解的取捨:效期短,使用者每隔幾分鐘就被踢出去;效期長,登出後撤銷不掉。stateless是一把雙面刃,伺服器沒有記錄,就沒有辦法讓一張已簽發的 token 提前失效。
兩個需求無法用同一支 token 同時滿足,因此拆成access token和refresh token,各自負責一件事。
Access Token 是效期 15 分鐘、以 RS256 簽章的 JWT,負責呼叫 API;token 本身不帶狀態,只靠簽章就能確認是誰簽發、是否過期,就算外洩,攻擊窗口也只有 15 分鐘。
Refresh Token 則是效期 7 天的 UUID,只負責換發新的 Access Token;它長效但有狀態,存在 Redis 裡並設定 7 天的存活時間,到期由 Redis 自動刪除;每次使用都必須查詢,也因此可以隨時手動刪除——由於只在換發時使用、頻率低,查一次儲存的成本可以接受。換句話說,把「高頻但短命」與「低頻但長效」分開,各自取用最適合的機制。
public RefreshResponse refresh(RefreshRequest request) {
// 1. Find refresh token in Redis
String userId = tokenRedisService.getRefreshTokenUserId(request.getRefreshToken());
if (userId == null) {
throw new AuthenticationException("Refresh Token 已失效");
}
// 2. Find user
User user = userMapper.selectById(userId);
if (user == null) {
throw new AuthenticationException("Refresh Token 已失效");
}
// 3. Generate new access token
String accessToken = jwtUtils.generateAccessToken(user.getUserId());
return new RefreshResponse(accessToken, accessTokenExpiresIn);
}
換發的第一步是拿 refresh token 去 Redis 查使用者,查不到就回「已失效」。所以要讓一顆 refresh token 作廢,只要刪掉 Redis 裡那筆資料就好,登出時做的正是這件事。
刪掉 Refresh Token 只能阻止換發,已經發出去的 Access Token 在剩餘效期內仍然有效,因此把它的 jti 放進黑名單,並且讓黑名單的 TTL 等於 token 的剩餘壽命:
public void logout(String refreshTokenId, String accessToken) {
// Blacklist access token in Redis
if (accessToken != null) {
try {
Claims claims = jwtUtils.parseToken(accessToken);
String jti = claims.getId();
if (jti != null) {
long remaining = jwtUtils.getRemainingSeconds(claims);
tokenRedisService.blacklistAccessToken(jti, remaining);
}
} catch (Exception e) {
// Access token may already be expired — ignore
}
}
// Delete refresh token from Redis
if (refreshTokenId != null) {
tokenRedisService.deleteRefreshToken(refreshTokenId);
}
}
Token 本身仍然不帶狀態,變的是驗證流程:驗完簽章與效期之後,還要再查一次 Redis 黑名單。每次請求因此多了一次 I/O,驗證不再是純 stateless,只能在stateless跟「登出能立即生效」取捨。