前提
這是《冷飯新炒》系列的第五篇文章。
本文會翻炒一個用以產生訪問令牌的開源標準JWT
,介紹JWT
的規範、底層實現原理、基本使用和應用場景。
JWT規範
很可惜維基百科上沒有搜尋到JWT
的條目,但是從jwt.io
的首頁展示圖中,可以看到描述:
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties
從這段文字中可以提取到JWT
的規範檔案RFC 7519
,裡面有詳細地介紹JWT
的基本概念,Claims
的含義、佈局和演算法實現等,下面逐個展開擊破。
JWT基本概念
JWT
全稱是JSON Web Token
,如果從字面上理解感覺是基於JSON
格式用於網路傳輸的令牌。實際上,JWT
是一種緊湊的Claims
宣告格式,旨在用於空間受限的環境進行傳輸,常見的場景如HTTP
授權請求頭引數和URI
查詢引數。JWT
會把Claims
轉換成JSON
格式,而這個JSON
內容將會應用為JWS
結構的有效載荷或者應用為JWE
結構的(加密處理後的)原始字串,通過訊息認證碼(Message Authentication Code
或者簡稱MAC
)和/或者加密操作對Claims
進行數字簽名或者完整性保護。
這裡有三個概念在其他規範檔案中,簡單提一下:
JWE
(規範檔案RFC 7516
):JSON Web Encryption
,表示基於JSON
資料結構的加密內容,加密機制對任意八位位元組序列進行加密、提供完整性保護和提高破解難度,JWE
中的緊湊序列化佈局如下
BASE64URL(UTF8(JWE Protected Header)) || '.' ||
BASE64URL(JWE Encrypted Key) || '.' ||
BASE64URL(JWE Initialization Vector) || '.' ||
BASE64URL(JWE Ciphertext) || '.' ||
BASE64URL(JWE Authentication Tag)
JWS
(規範檔案RFC 7515
):JSON Web Signature
,表示使用JSON
資料結構和BASE64URL
編碼表示經過數字簽名或訊息認證碼(MAC
)認證的內容,數字簽名或者MAC
能夠提供完整性保護,JWS
中的緊湊序列化佈局如下:
ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' ||
BASE64URL(JWS Payload)) || '.' ||
BASE64URL(JWS Signature)
JWA
(規範檔案RFC 7518
):JSON Web Algorithm
,JSON Web
演算法,數字簽名或者MAC
演算法,應用於JWS
的可用演算法列表如下:
總的來說,JWT
其實有兩種實現,基於JWE
實現的依賴於加解密演算法、BASE64URL
編碼和身份認證等手段提高傳輸的Claims
的被破解難度,而基於JWS
的實現使用了BASE64URL
編碼和數字簽名的方式對傳輸的Claims
提供了完整性保護,也就是僅僅保證傳輸的Claims
內容不被篡改,但是會暴露明文。目前主流的JWT
框架中大部分都沒有實現JWE
,所以下文主要通過JWS
的實現方式進行深入探討。
JWT中的Claims
Claim
有索賠、聲稱、要求或者權利要求的含義,但是筆者覺得任一個翻譯都不怎麼合乎語義,這裡保留Claim
關鍵字直接作為命名。JWT
的核心作用就是保護Claims
的完整性(或者資料加密),保證JWT
傳輸的過程中Claims
不被篡改(或者不被破解)。Claims
在JWT
原始內容中是一個JSON
格式的字串,其中單個Claim
是K-V
結構,作為JsonNode
中的一個field-value
,這裡列出常用的規範中預定義好的Claim
:
簡稱 | 全稱 | 含義 |
---|---|---|
iss | Issuer | 發行方 |
sub | Subject | 主體 |
aud | Audience | (接收)目標方 |
exp | Expiration Time | 過期時間 |
nbf | Not Before | 早於該定義的時間的JWT 不能被接受處理 |
iat | Issued At | JWT 發行時的時間戳 |
jti | JWT ID | JWT 的唯一標識 |
這些預定義的Claim
並不要求強制使用,何時選用何種Claim
完全由使用者決定,而為了使JWT
更加緊湊,這些Claim
都使用了簡短的命名方式去定義。在不和內建的Claim
衝突的前提下,使用者可以自定義新的公共Claim
,如:
簡稱 | 全稱 | 含義 |
---|---|---|
cid | Customer ID | 客戶ID |
rid | Role ID | 角色ID |
一定要注意,在JWS
實現中,Claims
會作為payload
部分進行BASE64
編碼,明文會直接暴露,敏感資訊一般不應該設計為一個自定義Claim
。
JWT中的Header
在JWT
規範檔案中稱這些Header
為JOSE Header
,JOSE
的全稱為Javascript Object Signature Encryption
,也就是Javascript
物件簽名和加密框架,JOSE Header
其實就是Javascript
物件簽名和加密的頭部引數。下面列舉一下JWS
中常用的Header
:
簡稱 | 全稱 | 含義 |
---|---|---|
alg | Algorithm | 用於保護JWS 的加解密演算法 |
jku | JWK Set URL | 一組JSON 編碼的公共金鑰的URL ,其中一個是用於對JWS 進行數字簽名的金鑰 |
jwk | JSON Web Key | 用於對JWS 進行數字簽名的金鑰相對應的公共金鑰 |
kid | Key ID | 用於保護JWS 進的金鑰 |
x5u | X.509 URL | X.509 相關 |
x5c | X.509 Certificate Chain | X.509 相關 |
x5t | X.509 Certificate SHA-1 Thumbprin | X.509 相關 |
x5t#S256 | X.509 Certificate SHA-256 Thumbprint | X.509 相關 |
typ | Type | 型別,例如JWT 、JWS 或者JWE 等等 |
cty | Content Type | 內容型別,決定payload 部分的MediaType |
最常見的兩個Header
就是alg
和typ
,例如:
{
"alg": "HS256",
"typ": "JWT"
}
JWT的佈局
主要介紹JWS
的佈局,前面已經提到過,JWS
的緊湊佈局如下:
ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' ||
BASE64URL(JWS Payload)) || '.' ||
BASE64URL(JWS Signature)
其實還有非緊湊佈局,會通過一個JSON
結構完整地展示Header
引數、Claims
和分組簽名:
{
"payload":"<payload contents>",
"signatures":[
{"protected":"<integrity-protected header 1 contents>",
"header":<non-integrity-protected header 1 contents>,
"signature":"<signature 1 contents>"},
...
{"protected":"<integrity-protected header N contents>",
"header":<non-integrity-protected header N contents>,
"signature":"<signature N contents>"}]
}
非緊湊佈局還有一個扁平化的表示形式:
{
"payload":"<payload contents>",
"protected":"<integrity-protected header contents>",
"header":<non-integrity-protected header contents>,
"signature":"<signature contents>"
}
其中Header
引數部分可以參看上一小節,而簽名部分可以參看下一小節,剩下簡單提一下payload
部分,payload
(有效載荷)其實就是完整的Claims
,假設Claims
的JSON
形式是:
{
"iss": "throwx",
"jid": 1
}
那麼扁平化非緊湊格式下的payload
節點就是:
{
......
"payload": {
"iss": "throwx",
"jid": 1
}
......
}
JWS簽名演算法
JWS
簽名生成依賴於雜湊或者加解密演算法,可以使用的演算法見前面貼出的圖,例如HS256
,具體是HMAC SHA-256
,也就是通過雜湊演算法SHA-256
對於編碼後的Header
和Claims
字串進行一次雜湊計算,簽名生成的虛擬碼如下:
## 不進行編碼
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
256 bit secret key
)
## 進行編碼
base64UrlEncode(
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload)
[256 bit secret key])
)
其他演算法的操作基本相似,生成好的簽名直接加上一個前置的.
拼接在base64UrlEncode(header).base64UrlEncode(payload)
之後就生成完整的JWS
。
JWT的生成、解析和校驗
前面已經分析過JWT
的一些基本概念、佈局和簽名演算法,這裡根據前面的理論進行JWT
的生成、解析和校驗操作。先引入common-codec
庫簡化一些編碼和加解密操作,引入一個主流的JSON
框架做序列化和反序列化:
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.11.0</version>
</dependency>
為了簡單起見,Header
引數寫死為:
{
"alg": "HS256",
"typ": "JWT"
}
使用的簽名演算法是HMAC SHA-256
,輸入的加密金鑰長度必須為256 bit
(如果單純用英文和數字組成的字元,要32
個字元),這裡為了簡單起見,用00000000111111112222222233333333
作為KEY
。定義Claims
部分如下:
{
"iss": "throwx",
"jid": 10087, # <---- 這裡有個筆誤,本來打算寫成jti,後來發現寫錯了,不打算改
"exp": 1613227468168 # 20210213
}
生成JWT
的程式碼如下:
@Slf4j
public class JsonWebToken {
private static final String KEY = "00000000111111112222222233333333";
private static final String DOT = ".";
private static final Map<String, String> HEADERS = new HashMap<>(8);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
HEADERS.put("alg", "HS256");
HEADERS.put("typ", "JWT");
}
String generateHeaderPart() throws JsonProcessingException {
byte[] headerBytes = OBJECT_MAPPER.writeValueAsBytes(HEADERS);
String headerPart = new String(Base64.encodeBase64(headerBytes,false ,true), StandardCharsets.US_ASCII);
log.info("生成的Header部分為:{}", headerPart);
return headerPart;
}
String generatePayloadPart(Map<String, Object> claims) throws JsonProcessingException {
byte[] payloadBytes = OBJECT_MAPPER.writeValueAsBytes(claims);
String payloadPart = new String(Base64.encodeBase64(payloadBytes,false ,true), StandardCharsets.UTF_8);
log.info("生成的Payload部分為:{}", payloadPart);
return payloadPart;
}
String generateSignaturePart(String headerPart, String payloadPart) {
String content = headerPart + DOT + payloadPart;
Mac mac = HmacUtils.getInitializedMac(HmacAlgorithms.HMAC_SHA_256, KEY.getBytes(StandardCharsets.UTF_8));
byte[] output = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
String signaturePart = new String(Base64.encodeBase64(output, false ,true), StandardCharsets.UTF_8);
log.info("生成的Signature部分為:{}", signaturePart);
return signaturePart;
}
public String generate(Map<String, Object> claims) throws Exception {
String headerPart = generateHeaderPart();
String payloadPart = generatePayloadPart(claims);
String signaturePart = generateSignaturePart(headerPart, payloadPart);
String jws = headerPart + DOT + payloadPart + DOT + signaturePart;
log.info("生成的JWT為:{}", jws);
return jws;
}
public static void main(String[] args) throws Exception {
Map<String, Object> claims = new HashMap<>(8);
claims.put("iss", "throwx");
claims.put("jid", 10087L);
claims.put("exp", 1613227468168L);
JsonWebToken jsonWebToken = new JsonWebToken();
System.out.println("自行生成的JWT:" + jsonWebToken.generate(claims));
}
}
執行輸出日誌如下:
23:37:48.743 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Header部分為:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
23:37:48.747 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Payload部分為:eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9
23:37:48.748 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Signature部分為:7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
23:37:48.749 [main] INFO club.throwable.jwt.JsonWebToken - 生成的JWT為:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
自行生成的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
可以在jwt.io
上驗證一下:
解析JWT
的過程是構造JWT
的逆向過程,首先基於點號.
分三段,然後分別進行BASE64
解碼,然後得到三部分的明文,頭部引數和有效載荷需要做一次JSON
反序列化即可還原各個部分的JSON
結構:
public Map<Part, PartContent> parse(String jwt) throws Exception {
System.out.println("當前解析的JWT:" + jwt);
Map<Part, PartContent> result = new HashMap<>(8);
// 這裡暫且認為所有的輸入JWT的格式都是合法的
StringTokenizer tokenizer = new StringTokenizer(jwt, DOT);
String[] jwtParts = new String[3];
int idx = 0;
while (tokenizer.hasMoreElements()) {
jwtParts[idx] = tokenizer.nextToken();
idx++;
}
String headerPart = jwtParts[0];
PartContent headerContent = new PartContent();
headerContent.setRawContent(headerPart);
headerContent.setPart(Part.HEADER);
headerPart = new String(Base64.decodeBase64(headerPart), StandardCharsets.UTF_8);
headerContent.setPairs(OBJECT_MAPPER.readValue(headerPart, new TypeReference<Map<String, Object>>() {
}));
result.put(Part.HEADER, headerContent);
String payloadPart = jwtParts[1];
PartContent payloadContent = new PartContent();
payloadContent.setRawContent(payloadPart);
payloadContent.setPart(Part.PAYLOAD);
payloadPart = new String(Base64.decodeBase64(payloadPart), StandardCharsets.UTF_8);
payloadContent.setPairs(OBJECT_MAPPER.readValue(payloadPart, new TypeReference<Map<String, Object>>() {
}));
result.put(Part.PAYLOAD, payloadContent);
String signaturePart = jwtParts[2];
PartContent signatureContent = new PartContent();
signatureContent.setRawContent(signaturePart);
signatureContent.setPart(Part.SIGNATURE);
result.put(Part.SIGNATURE, signatureContent);
return result;
}
enum Part {
HEADER,
PAYLOAD,
SIGNATURE
}
@Data
public static class PartContent {
private Part part;
private String rawContent;
private Map<String, Object> pairs;
}
這裡嘗試用之前生產的JWT
進行解析:
public static void main(String[] args) throws Exception {
JsonWebToken jsonWebToken = new JsonWebToken();
String jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs";
Map<Part, PartContent> parseResult = jsonWebToken.parse(jwt);
System.out.printf("解析結果如下:\nHEADER:%s\nPAYLOAD:%s\nSIGNATURE:%s%n",
parseResult.get(Part.HEADER),
parseResult.get(Part.PAYLOAD),
parseResult.get(Part.SIGNATURE)
);
}
解析結果如下:
當前解析的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
解析結果如下:
HEADER:PartContent(part=HEADER, rawContent=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9, pairs={typ=JWT, alg=HS256})
PAYLOAD:PartContent(part=PAYLOAD, rawContent=eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9, pairs={iss=throwx, jid=10087, exp=1613227468168})
SIGNATURE:PartContent(part=SIGNATURE, rawContent=7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs, pairs=null)
驗證JWT
建立在解析JWT
完成的基礎之上,需要對解析出來的頭部引數和有效載做一次MAC
簽名,與解析出來的簽名做校對。另外,可以自定義校驗具體的Claim
項,如過期時間和發行者等。一般校驗失敗會針對不同的情況定製不同的執行時異常便於區分場景,這裡為了方便統一丟擲IllegalStateException
:
public void verify(String jwt) throws Exception {
System.out.println("當前校驗的JWT:" + jwt);
Map<Part, PartContent> parseResult = parse(jwt);
PartContent headerContent = parseResult.get(Part.HEADER);
PartContent payloadContent = parseResult.get(Part.PAYLOAD);
PartContent signatureContent = parseResult.get(Part.SIGNATURE);
String signature = generateSignaturePart(headerContent.getRawContent(), payloadContent.getRawContent());
if (!Objects.equals(signature, signatureContent.getRawContent())) {
throw new IllegalStateException("簽名校驗異常");
}
String iss = payloadContent.getPairs().get("iss").toString();
// iss校驗
if (!Objects.equals(iss, "throwx")) {
throw new IllegalStateException("ISS校驗異常");
}
long exp = Long.parseLong(payloadContent.getPairs().get("exp").toString());
// exp校驗,有效期14天
if (System.currentTimeMillis() - exp > 24 * 3600 * 1000 * 14) {
throw new IllegalStateException("exp校驗異常,JWT已經過期");
}
// 省略其他校驗項
System.out.println("JWT校驗通過");
}
類似地,用上面生成過的JWT
進行驗證,結果如下:
當前校驗的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
當前解析的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
23:33:00.174 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Signature部分為:7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
JWT校驗通過
上面的程式碼存在硬編碼問題,只是為了用最簡單的JWS
實現方式重新實現了JWT
的生成、解析和校驗過程,演算法也使用了複雜程度和安全性極低的HS256
,所以在生產中並不推薦花大量時間去實現JWS
,可以選用現成的JWT
類庫,如auth0
和jjwt
。
JWT的使用場景和實戰
JWT
本質是一個令牌,更多場景下是作為會話ID
(session_id
)使用,作用是'維持會話的粘性'
和攜帶認證資訊(如果用JWT
術語,應該是安全地傳遞Claims
)。筆者記得很久以前使用的一種Session ID
解決方案是由服務端生成和持久化Session ID
,返回的Session ID
需要寫入使用者的Cookie
,然後使用者每次請求必須攜帶Cookie
,Session ID
會對映使用者的一些認證資訊,這一切都是由服務端管理,一個很常見的例子就是Tomcat
容器中出現的J(ava)SESSIONID
。與之前的方案不同,JWT
是一種無狀態的令牌,它並不需要由服務端儲存,攜帶的資料或者會話的資料都不需要持久化,使用JWT
只需要關注Claims
的完整性和合法性即可,生成JWT
時候所有有效資料已經通過編碼儲存在JWT
字串中。正因JWT
是無狀態的,一旦頒發後得到JWT
的客戶端都可以通過它與服務端互動,JWT
一旦洩露有可能造成嚴重安全問題,因此實踐的時候一般需要做幾點:
JWT
需要設定有效期,也就是exp
這個Claim
必須啟用和校驗JWT
需要建立黑名單,一般使用jti
這個Claim
即可,技術上可以使用布隆過濾器加資料庫的組合(數量少的情況下簡單操作甚至可以用Redis
的SET
資料型別)JWS
的簽名演算法儘可能使用安全性高的演算法,如RSXXX
Claims
儘可能不要寫入敏感資訊- 高風險場景如支付操作等不能僅僅依賴
JWT
認證,需要進行簡訊、指紋等二次認證
PS:身邊有不少同事所在的專案會把JWT持久化,其實這違背了JWT的設計理念,把JWT當成傳統的會話ID使用了
JWT
一般用於認證場景,搭配API
閘道器使用效果甚佳。多數情況下,API
閘道器會存在一些通用不需要認證的介面,其他則是需要認證JWT
合法性並且提取JWT
中的訊息載荷內容進行呼叫,針對這個場景:
- 對於控制器入口可以提供一個自定義註解標識特定介面需要進行
JWT
認證,這個場景在Spring Cloud Gateway
中需要自定義實現一個JWT
認證的WebFilter
- 對於單純的路由和轉發可以提供一個
URI
白名單集合,命中白名單則不需要進行JWT
認證,這個場景在Spring Cloud Gateway
中需要自定義實現一個JWT
認證的GlobalFilter
下面就Spring Cloud Gateway
和jjwt
,貼一些骨幹程式碼,限於篇幅不進行細節展開。引入依賴:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR10</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.18</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
</dependencies>
然後編寫JwtSpi
和對應的實現HMAC256JwtSpiImpl
:
@Data
public class CreateJwtDto {
private Long customerId;
private String customerName;
private String customerPhone;
}
@Data
public class JwtCacheContent {
private Long customerId;
private String customerName;
private String customerPhone;
}
@Data
public class VerifyJwtResultDto {
private Boolean valid;
private Throwable throwable;
private long jwtId;
private JwtCacheContent content;
}
public interface JwtSpi {
/**
* 生成JWT
*
* @param dto dto
* @return String
*/
String generate(CreateJwtDto dto);
/**
* 校驗JWT
*
* @param jwt jwt
* @return VerifyJwtResultDto
*/
VerifyJwtResultDto verify(String jwt);
/**
* 把JWT新增到封禁名單中
*
* @param jwtId jwtId
*/
void blockJwt(long jwtId);
/**
* 判斷JWT是否在封禁名單中
*
* @param jwtId jwtId
* @return boolean
*/
boolean isInBlockList(long jwtId);
}
@Component
public class HMAC256JwtSpiImpl implements JwtSpi, InitializingBean, EnvironmentAware {
private SecretKey secretKey;
private Environment environment;
private int minSeed;
private String issuer;
private int seed;
private Random random;
@Override
public void afterPropertiesSet() throws Exception {
String secretKey = Objects.requireNonNull(environment.getProperty("jwt.hmac.secretKey"));
this.minSeed = Objects.requireNonNull(environment.getProperty("jwt.exp.seed.min", Integer.class));
int maxSeed = Objects.requireNonNull(environment.getProperty("jwt.exp.seed.max", Integer.class));
this.issuer = Objects.requireNonNull(environment.getProperty("jwt.issuer"));
this.random = new Random();
this.seed = (maxSeed - minSeed);
this.secretKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256");
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
@Override
public String generate(CreateJwtDto dto) {
long duration = this.random.nextInt(this.seed) + minSeed;
Map<String, Object> claims = new HashMap<>(8);
claims.put("iss", issuer);
// 這裡的jti最好用類似雪花演算法之類的序列演算法生成,確保唯一性
claims.put("jti", dto.getCustomerId());
claims.put("uid", dto.getCustomerId());
claims.put("exp", TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) + duration);
String jwt = Jwts.builder()
.setHeaderParam("typ", "JWT")
.signWith(this.secretKey, SignatureAlgorithm.HS256)
.addClaims(claims)
.compact();
// 這裡需要快取uid->JwtCacheContent的資訊
JwtCacheContent content = new JwtCacheContent();
// redis.set(KEY[uid],toJson(content),expSeconds);
return jwt;
}
@Override
public VerifyJwtResultDto verify(String jwt) {
JwtParser parser = Jwts.parserBuilder()
.requireIssuer(this.issuer)
.setSigningKey(this.secretKey)
.build();
VerifyJwtResultDto resultDto = new VerifyJwtResultDto();
try {
Jws<Claims> parseResult = parser.parseClaimsJws(jwt);
Claims claims = parseResult.getBody();
long jti = Long.parseLong(claims.getId());
if (isInBlockList(jti)) {
throw new IllegalArgumentException(String.format("jti is in block list,[i:%d]", jti));
}
long uid = claims.get("uid", Long.class);
// JwtCacheContent content = JSON.parse(redis.get(KEY[uid]),JwtCacheContent.class);
// resultDto.setContent(content);
resultDto.setValid(Boolean.TRUE);
} catch (Exception e) {
resultDto.setValid(Boolean.FALSE);
resultDto.setThrowable(e);
}
return resultDto;
}
@Override
public void blockJwt(long jwtId) {
}
@Override
public boolean isInBlockList(long jwtId) {
return false;
}
}
然後是JwtGlobalFilter
和JwtWebFilter
的非完全實現:
@Component
public class JwtGlobalFilter implements GlobalFilter, Ordered, EnvironmentAware {
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private List<String> accessUriList;
@Autowired
private JwtSpi jwtSpi;
private static final String JSON_WEB_TOKEN_KEY = "X-TOKEN";
private static final String UID_KEY = "X-UID";
private static final String JWT_ID_KEY = "X-JTI";
@Override
public void setEnvironment(Environment environment) {
accessUriList = Arrays.asList(Objects.requireNonNull(environment.getProperty("jwt.access.uris"))
.split(","));
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// OPTIONS 請求直接放行
HttpMethod method = request.getMethod();
if (Objects.nonNull(method) && Objects.equals(method, HttpMethod.OPTIONS)) {
return chain.filter(exchange);
}
// 獲取請求路徑
String requestPath = request.getPath().value();
// 命中請求路徑白名單
boolean matchWhiteRequestPathList = Optional.ofNullable(accessUriList)
.map(paths -> paths.stream().anyMatch(path -> pathMatcher.match(path, requestPath)))
.orElse(false);
if (matchWhiteRequestPathList) {
return chain.filter(exchange);
}
HttpHeaders headers = request.getHeaders();
String token = headers.getFirst(JSON_WEB_TOKEN_KEY);
if (!StringUtils.hasLength(token)) {
throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), "token is null");
}
VerifyJwtResultDto resultDto = jwtSpi.verify(token);
if (Objects.equals(resultDto.getValid(), Boolean.FALSE)) {
throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), resultDto.getThrowable());
}
headers.set(JWT_ID_KEY, String.valueOf(resultDto.getJwtId()));
headers.set(UID_KEY, String.valueOf(resultDto.getContent().getCustomerId()));
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 1;
}
}
@Component
public class JwtWebFilter implements WebFilter {
@Autowired
private RequestMappingHandlerMapping requestMappingHandlerMapping;
@Autowired
private JwtSpi jwtSpi;
private static final String JSON_WEB_TOKEN_KEY = "X-TOKEN";
private static final String UID_KEY = "X-UID";
private static final String JWT_ID_KEY = "X-JTI";
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// OPTIONS 請求直接放行
HttpMethod method = exchange.getRequest().getMethod();
if (Objects.nonNull(method) && Objects.equals(method, HttpMethod.OPTIONS)) {
return chain.filter(exchange);
}
HandlerMethod handlerMethod = requestMappingHandlerMapping.getHandlerInternal(exchange).block();
if (Objects.isNull(handlerMethod)) {
return chain.filter(exchange);
}
RequireJWT typeAnnotation = handlerMethod.getBeanType().getAnnotation(RequireJWT.class);
RequireJWT methodAnnotation = handlerMethod.getMethod().getAnnotation(RequireJWT.class);
if (Objects.isNull(typeAnnotation) && Objects.isNull(methodAnnotation)) {
return chain.filter(exchange);
}
HttpHeaders headers = exchange.getRequest().getHeaders();
String token = headers.getFirst(JSON_WEB_TOKEN_KEY);
if (!StringUtils.hasLength(token)) {
throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), "token is null");
}
VerifyJwtResultDto resultDto = jwtSpi.verify(token);
if (Objects.equals(resultDto.getValid(), Boolean.FALSE)) {
throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), resultDto.getThrowable());
}
headers.set(JWT_ID_KEY, String.valueOf(resultDto.getJwtId()));
headers.set(UID_KEY, String.valueOf(resultDto.getContent().getCustomerId()));
return chain.filter(exchange);
}
}
最後是一些配置屬性:
jwt.hmac.secretKey='00000000111111112222222233333333'
jwt.exp.seed.min=360000
jwt.exp.seed.max=8640000
jwt.issuer='throwx'
jwt.access.uris=/index,/actuator/*
使用JWT曾經遇到的坑
筆者負責的API
閘道器使用了JWT
應用於認證場景,演算法上使用了安全性稍高的RS256
,使用RSA
演算法進行簽名生成。專案上線初期,JWT
的過期時間都固定設定為7
天,生產日誌發現該API
閘道器週期性發生"假死"現象,具體表現為:
Nginx
自檢週期性出現自檢介面呼叫超時,提示部分或者全部API
閘道器節點當機API
閘道器所在機器的CPU
週期性飆高,在使用者訪問量低的時候表現平穩- 通過
ELK
進行日誌排查,發現故障出現時段有JWT
集中性過期和重新生成的日誌痕跡
排查結果表明JWT
集中過期和重新生成時候使用RSA
演算法進行簽名是CPU
密集型操作,同時重新生成大量JWT
會導致服務所在機器的CPU
超負載工作。初步的解決方案是:
JWT
生成的時候,過期時間新增一個隨機數,例如360000(1小時的毫秒數) ~ 8640000(24小時的毫秒數)
之間取一個隨機值新增到當前時間戳加7
天得到exp
值
這個方法,對於一些老使用者營銷場景(老使用者長時間沒有登入,他們客戶端快取的JWT
一般都已經過期)沒有效果。有時候運營會通過營銷活動喚醒老使用者,大量老使用者重新登入有可能出現爆發性大批量重新生成JWT
的情況,對於這個場景提出兩個解決思路:
- 首次生成
JWT
時候,考慮延長過期時間,但是時間越長,風險越大 - 提升
API
閘道器所在機器的硬體配置,特別是CPU
配置,現在很多雲廠商都有彈性擴容方案,可以很好應對這類突發流量場景
小結
主流的JWT
方案是JWS
,此方案是隻編碼和簽名,不加密,務必注意這一點,JWS
方案是無狀態並且不安全的,關鍵操作應該做多重認證,也要做好黑名單機制防止JWT
洩漏後造成安全性問題。JWT
不儲存在服務端,這既是它的優勢,同時也是它的劣勢。很多軟體架構都無法做到盡善盡美,這個時候只能權衡利弊。
參考資料:
(本文完 c-3-w e-a-20210219)