JWT概覽
JWT概念
JWT
是全稱是JSON WEB TOKEN
,是一個開放標準,用于將各方數據信息作為JSON格式進行對象傳遞,可以對數據進行可選的數字加密,可使用RSA
或ECDSA
進行公鑰/私鑰簽名。JWT
最常見的使用場景就是緩存當前用戶登錄信息,當用戶登錄成功之后,拿到JWT
,之后用戶的每一個請求在請求頭攜帶上Authorization
字段來辨別區分請求的用戶信息。且不需要額外的資源開銷。
JWT組成部分
JWT通常由一個頭部(Header)、一個負載(Payload)和一個簽名(Signature)三部分組成,這三部分之間用點(.)分隔。所以,一個完整的JWT看起來像這樣:
xxxxx.yyyyy.zzzzz
下面我們來詳細解析每一部分:
頭部(header)
頭部用于描述令牌的元數據,通常包含令牌的類型(即JWT)和所使用的簽名算法(如HMAC SHA256)。
typ
:表示令牌的類型,JWT令牌統一寫為"JWT"。alg
:表示簽名使用的算法,例如HMAC SHA256或RSA。
頭部信息會被進行Base64編碼,形成JWT的第一部分。
{ "typ": "JWT", "alg": "HS256"
}
負載(payload)
負載包含了JWT的聲明,即傳遞的數據,這些數據通常包括用戶信息和其他相關數據。聲明有三種類型:注冊的聲明、公共的聲明和私有的聲明。
- 注冊的聲明:這是一組預定義的聲明,它們不是強制的,但是推薦使用,以提供一組有用的、可互操作的聲明。如:
iat
(簽發時間)、exp
(過期時間)、aud
(接收方)、sub
(用戶唯一標識)、jti
(JWT唯一標識)等。 - 公共的聲明:可以定義任何名稱,但應避免與注冊的聲明名稱沖突。
- 私有的聲明:是提供者和消費者之間共同定義的聲明。
負載同樣會被Base64編碼,形成JWT的第二部分。
{ "sub": "1234567890", "name": "John Doe", "jti": "unique-jwt-id","admin": true
}
簽名(signature)
簽名將頭部和負載用指定的算法進行簽名,驗證JWT的真實性和完整性。當接收者收到JWT時,他們可以使用相同的算法和密鑰(對于HMAC算法)或使用公鑰(對于RSA或ECDSA算法)驗證簽名。如果兩個簽名匹配,那么JWT就是有效的。
簽名的過程如下:
- 先將Base64編碼后的頭部和負載數據用點號(
.
)連接起來。 - 使用指定的簽名算法(例如,HMAC SHA256、RSA、ECDSA)和密鑰對連接后的字符串進行簽名。
- 將生成的簽名部分進行Base64Url編碼,形成JWT的第三部分。
簽名部分也是經過Base64Url編碼的,形成JWT的第三部分。
HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
注意:雖然Base64Url編碼不是加密方式,但它可以確保JWT的字符串格式是緊湊的,并且容易在URL、POST參數或HTTP頭部中傳輸。
技術方案設計
單點登錄(SSO)
- 單點登錄(Single Sign-On, SSO) 是一種身份認證機制,允許用戶通過一次登錄即可訪問多個相互信任的應用系統,而無需重復輸入認證信息。
雙Token機制
- AccessToken
- 短期有效(如30分鐘),用于接口訪問。
- 客戶端每次請求API時攜帶。
- 不持久化存儲,僅通過簽名驗證合法性。
- RefreshToken
- 用于獲取新的Access Token,有效期長(如3天)。
- 僅在刷新令牌時傳輸,不直接訪問業務API。
- 必須持久化存儲(如Redis),服務端可主動使其失效。
- 簽名算法:使用RSA非對稱加密算法,減少內存占用,防止篡改,并方便后續拓展子系統。
無感刷新Token
- 客戶端將由于AccesssToken過期失敗的請求存儲起來,攜帶RefreshToken成功刷新Token后,將存儲的失敗請求重新發起,以此達到用戶無感的體驗。
- 服務端根據RefreshToken解析出userId和deviceId后,去Redis中查詢存儲的RefreshToken并進行比對,成功后生成新的AT和RT并返回
多端會話管理
- 同一賬號在不同設備登錄時,為每個設備生成獨立的RefreshToken。
- Redis中以
userId:deviceId
為鍵存儲RefreshToken,過期時間設置為RefreshToken的過期時間。
廢棄令牌移除
- Redis中以
blacklist:token
為鍵存儲AccessToken黑名單,鍵值對的過期時間設置為AccessToken的剩余有效期。 - 直接刪除Redis中的RefreshToken。
最佳實踐
總體流程
JWT工具類
// JWT工具類
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;@Component
public class JwtUtil {@Value("${jwt.secret}")private String secret;@Value("${jwt.access.expiration}")private long accessExpiration;@Value("${jwt.refresh.expiration}")private long refreshExpiration;public String generateAccessToken(String username) {return Jwts.builder().setSubject(username).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + accessExpiration)).signWith(SignatureAlgorithm.HS512, secret).compact();}public String generateRefreshToken(String username) {return Jwts.builder().setSubject(username).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + refreshExpiration)).signWith(SignatureAlgorithm.HS512, secret).compact();}public boolean validateToken(String token) {try {Jwts.parser().setSigningKey(secret).parseClaimsJws(token);return true;} catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {return false;}}public String getUsernameFromToken(String token) {return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody().getSubject();}
}
Redis服務類
// Redis服務類
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;@Service
public class RedisService {private final StringRedisTemplate redisTemplate;public RedisService(StringRedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;}public void saveRefreshToken(String refreshToken, String username) {redisTemplate.opsForValue().set("refresh_token:" + refreshToken, username, 7, TimeUnit.DAYS);}public boolean isRefreshTokenValid(String refreshToken) {return redisTemplate.hasKey("refresh_token:" + refreshToken);}public void deleteRefreshToken(String refreshToken) {redisTemplate.delete("refresh_token:" + refreshToken);}public void addToBlacklist(String accessToken, long expirationMs) {redisTemplate.opsForValue().set("blacklist:" + accessToken, "invalid", expirationMs, TimeUnit.MILLISECONDS);}public boolean isInBlacklist(String accessToken) {return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + accessToken));}
}
Filter過濾器
// JWT過濾器
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;public class JwtFilter extends OncePerRequestFilter {private final JwtUtil jwtUtil;private final RedisService redisService;public JwtFilter(JwtUtil jwtUtil, RedisService redisService) {this.jwtUtil = jwtUtil;this.redisService = redisService;}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {String token = resolveToken(request);if (token == null) {filterChain.doFilter(request, response);return;}if (redisService.isInBlacklist(token)) {sendError(response, "Token invalid");return;}if (jwtUtil.validateToken(token)) {String username = jwtUtil.getUsernameFromToken(token);UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, null);SecurityContextHolder.getContext().setAuthentication(authentication);filterChain.doFilter(request, response);} else {sendError(response, "Token expired or invalid");}}private String resolveToken(HttpServletRequest request) {String bearerToken = request.getHeader("Authorization");if (bearerToken != null && bearerToken.startsWith("Bearer ")) {return bearerToken.substring(7);}return null;}private void sendError(HttpServletResponse response, String message) throws IOException {response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);response.getWriter().write(message);response.getWriter().flush();}
}
Controller類
// 控制器類
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;@RestController
public class AuthController {private final JwtUtil jwtUtil;private final RedisService redisService;public AuthController(JwtUtil jwtUtil, RedisService redisService) {this.jwtUtil = jwtUtil;this.redisService = redisService;}@PostMapping("/login")public ResponseEntity<?> login(@RequestBody LoginRequest request) {// 這里應添加用戶認證邏輯(如數據庫驗證)String username = request.getUsername();String accessToken = jwtUtil.generateAccessToken(username);String refreshToken = jwtUtil.generateRefreshToken(username);redisService.saveRefreshToken(refreshToken, username);return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));}@PostMapping("/refresh")public ResponseEntity<?> refreshToken(@RequestBody RefreshRequest request) {String refreshToken = request.getRefreshToken();if (!redisService.isRefreshTokenValid(refreshToken)) {return ResponseEntity.status(401).body("Invalid refresh token");}String username = jwtUtil.getUsernameFromToken(refreshToken);String newAccessToken = jwtUtil.generateAccessToken(username);String newRefreshToken = jwtUtil.generateRefreshToken(username);// 替換舊refreshTokenredisService.deleteRefreshToken(refreshToken);redisService.saveRefreshToken(newRefreshToken, username);// 將舊accessToken加入黑名單(可選)// long expiration = jwtUtil.getExpirationFromToken(refreshToken).getTime() - System.currentTimeMillis();// redisService.addToBlacklist(refreshToken, expiration);return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken));}// DTO類private static class LoginRequest {private String username;private String password;// getters/setters}private static class RefreshRequest {private String refreshToken;// getters/setters}private static class TokenResponse {private final String accessToken;private final String refreshToken;// constructor/getters}
}
問題解析
相比單Token的優勢
- 高安全性:用戶請求僅攜帶過期時間較短的AccessToken,即使令牌泄露,風險時間窗口也較小;用戶僅在請求刷新Token時攜帶RefreshToken
- 長會話:RefreshToken一般設置較長的過期時間,只要RT不過期用戶就無需重復登錄
引入Redis的作用
- 方便狀態管理:如果不存在Redis,用戶登出后只能等待Token過期才能被動失效,增加Token暴露風險;通過在Redis中引入黑名單blacklist,可以使得Token主動失效
- 多端會話管理:通過以
userId:deviceId
為鍵存儲不同設備的Token,實現同用戶多端登錄。通過刪除對應設備的鍵并加上黑名單,可以主動剔出對應設備 - 分布式一致性:若使用本地內存存儲 RT,在分布式多節點架構中,各節點無法共享 RT 狀態,導致用戶在一個節點退出后,其他節點仍認為 RT 有效。Redis作為集中式存儲,確保所有服務節點訪問同一份 RT 數據,狀態一致。
保證Token安全性
- 存儲安全性:AT存于內存或 SessionStorage(頁面關閉失效),而RT通過 HttpOnly; Secure; SameSite=Strict Cookie 存儲(XSS攻擊無效)。
- 傳輸安全性:開啟HTTPS,防止中間人攻擊(篡改、偽造和竊聽);AT通過
Authorization: Bearer {token}
請求頭傳遞,避免 URL 參數(防日志泄露),而RT通過Cookie
(標記HttpOnly; Secure
)傳輸。