冷飯新炒:理解JWT的實現(xiàn)原理和基本使用
前提
這是《冷飯新炒》系列的第五篇文章。

本文會翻炒一個用以產(chǎn)生訪問令牌的開源標準JWT,介紹JWT的規(guī)范、底層實現(xiàn)原理、基本使用和應用場景。
JWT規(guī)范
很可惜維基百科上沒有搜索到JWT的條目,但是從jwt.io的首頁展示圖中,可以看到描述:
?JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties
?
從這段文字中可以提取到JWT的規(guī)范文件RFC 7519,里面有詳細地介紹JWT的基本概念,Claims的含義、布局和算法實現(xiàn)等,下面逐個展開擊破。
JWT基本概念
JWT全稱是JSON Web Token,如果從字面上理解感覺是基于JSON格式用于網(wǎng)絡傳輸?shù)牧钆?。實際上,JWT是一種緊湊的Claims聲明格式,旨在用于空間受限的環(huán)境進行傳輸,常見的場景如HTTP授權請求頭參數(shù)和URI查詢參數(shù)。JWT會把Claims轉(zhuǎn)換成JSON格式,而這個JSON內(nèi)容將會應用為JWS結構的有效載荷或者應用為JWE結構的(加密處理后的)原始字符串,通過消息認證碼(Message Authentication Code或者簡稱MAC)和/或者加密操作對Claims進行數(shù)字簽名或者完整性保護。
這里有三個概念在其他規(guī)范文件中,簡單提一下:
JWE(規(guī)范文件RFC 7516):JSON Web Encryption,表示基于JSON數(shù)據(jù)結構的加密內(nèi)容,加密機制對任意八位字節(jié)序列進行加密、提供完整性保護和提高破解難度,JWE中的緊湊序列化布局如下
BASE64URL(UTF8(JWE?Protected?Header))?||?'.'?||
BASE64URL(JWE?Encrypted?Key)?||?'.'?||
BASE64URL(JWE?Initialization?Vector)?||?'.'?||
BASE64URL(JWE?Ciphertext)?||?'.'?||
BASE64URL(JWE?Authentication?Tag)
JWS(規(guī)范文件RFC 7515):JSON Web Signature,表示使用JSON數(shù)據(jù)結構和BASE64URL編碼表示經(jīng)過數(shù)字簽名或消息認證碼(MAC)認證的內(nèi)容,數(shù)字簽名或者MAC能夠提供完整性保護,JWS中的緊湊序列化布局如下:
ASCII(BASE64URL(UTF8(JWS?Protected?Header))?||?'.'?||?
BASE64URL(JWS?Payload))?||?'.'?||
BASE64URL(JWS?Signature)
JWA(規(guī)范文件RFC 7518):JSON Web Algorithm,JSON Web算法,數(shù)字簽名或者MAC算法,應用于JWS的可用算法列表如下:

總的來說,JWT其實有兩種實現(xiàn),基于JWE實現(xiàn)的依賴于加解密算法、BASE64URL編碼和身份認證等手段提高傳輸?shù)?code style="overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">Claims的被破解難度,而基于JWS的實現(xiàn)使用了BASE64URL編碼和數(shù)字簽名的方式對傳輸?shù)?code style="overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">Claims提供了完整性保護,也就是僅僅保證傳輸?shù)?code style="overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">Claims內(nèi)容不被篡改,但是會暴露明文。「目前主流的JWT框架中大部分都沒有實現(xiàn)JWE,所以下文主要通過JWS的實現(xiàn)方式進行深入探討」。
JWT中的Claims
Claim有索賠、聲稱、要求或者權利要求的含義,但是筆者覺得任一個翻譯都不怎么合乎語義,這里保留Claim關鍵字直接作為命名。JWT的核心作用就是保護Claims的完整性(或者數(shù)據(jù)加密),保證JWT傳輸?shù)倪^程中Claims不被篡改(或者不被破解)。Claims在JWT原始內(nèi)容中是一個JSON格式的字符串,其中單個Claim是K-V結構,作為JsonNode中的一個field-value,這里列出常用的規(guī)范中預定義好的Claim:
| 簡稱 | 全稱 | 含義 |
|---|---|---|
| iss | Issuer | 發(fā)行方 |
| sub | Subject | 主體 |
| aud | Audience | (接收)目標方 |
| exp | Expiration Time | 過期時間 |
| nbf | Not Before | 早于該定義的時間的JWT不能被接受處理 |
| iat | Issued At | JWT發(fā)行時的時間戳 |
| jti | JWT ID | JWT的唯一標識 |
這些預定義的Claim并不要求強制使用,何時選用何種Claim完全由使用者決定,而為了使JWT更加緊湊,這些Claim都使用了簡短的命名方式去定義。在不和內(nèi)建的Claim沖突的前提下,使用者可以自定義新的公共Claim,如:
| 簡稱 | 全稱 | 含義 |
|---|---|---|
| cid | Customer ID | 客戶ID |
| rid | Role ID | 角色ID |
一定要注意,在JWS實現(xiàn)中,Claims會作為payload部分進行BASE64編碼,明文會直接暴露,敏感信息一般不應該設計為一個自定義Claim。
JWT中的Header
在JWT規(guī)范文件中稱這些Header為JOSE Header,JOSE的全稱為Javascript Object Signature Encryption,也就是Javascript對象簽名和加密框架,JOSE Header其實就是Javascript對象簽名和加密的頭部參數(shù)。「下面列舉一下JWS中常用的Header」:
| 簡稱 | 全稱 | 含義 |
|---|---|---|
| alg | Algorithm | 用于保護JWS的加解密算法 |
| jku | JWK Set URL | 一組JSON編碼的公共密鑰的URL,其中一個是用于對JWS進行數(shù)字簽名的密鑰 |
| jwk | JSON Web Key | 用于對JWS進行數(shù)字簽名的密鑰相對應的公共密鑰 |
| 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 | 內(nèi)容類型,決定payload部分的MediaType |
最常見的兩個Header就是alg和typ,例如:
{
??"alg":?"HS256",
??"typ":?"JWT"
}
JWT的布局
主要介紹JWS的布局,前面已經(jīng)提到過,JWS的「緊湊布局」如下:
ASCII(BASE64URL(UTF8(JWS?Protected?Header))?||?'.'?||?
BASE64URL(JWS?Payload))?||?'.'?||
BASE64URL(JWS?Signature)
其實還有「非緊湊布局」,會通過一個JSON結構完整地展示Header參數(shù)、Claims和分組簽名:
{
????"payload":"" ,
????"signatures":[
????{"protected":"" ,
????"header":1?contents>,
????"signature":"" },
????...
????{"protected":"" ,
????"header":,
????"signature":"" }]
}
非緊湊布局還有一個扁平化的表示形式:
{
????"payload":"" ,
????"protected":"" ,
????"header":,
????"signature":""
}
其中Header參數(shù)部分可以參看上一小節(jié),而簽名部分可以參看下一小節(jié),剩下簡單提一下payload部分,payload(有效載荷)其實就是完整的Claims,假設Claims的JSON形式是:
{
???"iss":?"throwx",
???"jid":?1
}
那么扁平化非緊湊格式下的payload節(jié)點就是:
{??
???......
???"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的生成、解析和校驗
前面已經(jīng)分析過JWT的一些基本概念、布局和簽名算法,這里根據(jù)前面的理論進行JWT的生成、解析和校驗操作。先引入common-codec庫簡化一些編碼和加解密操作,引入一個主流的JSON框架做序列化和反序列化:
<dependency>
????<groupId>commons-codecgroupId>
????<artifactId>commons-codecartifactId>
????<version>1.15version>
dependency>
<dependency>
????<groupId>com.fasterxml.jackson.coregroupId>
????<artifactId>jackson-databindartifactId>
????<version>2.11.0version>
dependency>
為了簡單起見,Header參數(shù)寫死為:
{
??"alg":?"HS256",
??"typ":?"JWT"
}
使用的簽名算法是HMAC SHA-256,輸入的加密密鑰長度必須為256 bit(如果單純用英文和數(shù)字組成的字符,要32個字符),這里為了簡單起見,用00000000111111112222222233333333作為KEY。定義Claims部分如下:
{
??"iss":?"throwx",
??"jid":?10087,??#?<----?這里有個筆誤,本來打算寫成jti,后來發(fā)現(xiàn)寫錯了,不打算改
??"exp":?1613227468168?????#?20210213????
}
生成JWT的代碼如下:
@Slf4j
public?class?JsonWebToken?{
????private?static?final?String?KEY?=?"00000000111111112222222233333333";
????private?static?final?String?DOT?=?".";
????private?static?final?Map?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?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?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?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));
????}
}
執(zhí)行輸出日志如下:
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解碼,然后得到三部分的明文,頭部參數(shù)和有效載荷需要做一次JSON反序列化即可還原各個部分的JSON結構:
public?Map?parse(String?jwt)?throws?Exception? {
????System.out.println("當前解析的JWT:"?+?jwt);
????Map?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 這里嘗試用之前生產(chǎn)的JWT進行解析:
public?static?void?main(String[]?args)?throws?Exception?{
????JsonWebToken?jsonWebToken?=?new?JsonWebToken();
????String?jwt?=?"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs";
????Map?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完成的基礎之上,需要對解析出來的頭部參數(shù)和有效載做一次MAC簽名,與解析出來的簽名做校對。另外,可以自定義校驗具體的Claim項,如過期時間和發(fā)行者等。一般校驗失敗會針對不同的情況定制不同的運行時異常便于區(qū)分場景,這里為了方便統(tǒng)一拋出IllegalStateException:
public?void?verify(String?jwt)?throws?Exception?{
????System.out.println("當前校驗的JWT:"?+?jwt);
????Map?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已經(jīng)過期");
????}
????//?省略其他校驗項
????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實現(xiàn)方式重新實現(xiàn)了JWT的生成、解析和校驗過程」,算法也使用了復雜程度和安全性極低的HS256,所以在生產(chǎn)中并不推薦花大量時間去實現(xiàn)JWS,可以選用現(xiàn)成的JWT類庫,如auth0和jjwt。
JWT的使用場景和實戰(zhàn)
JWT本質(zhì)是一個令牌,更多場景下是作為會話ID(session_id)使用,作用是'維持會話的粘性'和攜帶認證信息(如果用JWT術語,應該是安全地傳遞Claims)。筆者記得很久以前使用的一種Session ID解決方案是由服務端生成和持久化Session ID,返回的Session ID需要寫入用戶的Cookie,然后用戶每次請求必須攜帶Cookie,Session ID會映射用戶的一些認證信息,這一切都是由服務端管理,一個很常見的例子就是Tomcat容器中出現(xiàn)的J(ava)SESSIONID。與之前的方案不同,JWT是一種無狀態(tài)的令牌,它并不需要由服務端保存,攜帶的數(shù)據(jù)或者會話的數(shù)據(jù)都不需要持久化,使用JWT只需要關注Claims的完整性和合法性即可,生成JWT時候所有有效數(shù)據(jù)已經(jīng)通過編碼存儲在JWT字符串中。正因JWT是無狀態(tài)的,一旦頒發(fā)后得到JWT的客戶端都可以通過它與服務端交互,JWT一旦泄露有可能造成嚴重安全問題,因此實踐的時候一般需要做幾點:
JWT需要設置有效期,也就是exp這個Claim必須啟用和校驗JWT需要建立黑名單,一般使用jti這個Claim即可,技術上可以使用布隆過濾器加數(shù)據(jù)庫的組合(數(shù)量少的情況下簡單操作甚至可以用Redis的SET數(shù)據(jù)類型)JWS的簽名算法盡可能使用安全性高的算法,如RSXXXClaims盡可能不要寫入敏感信息高風險場景如支付操作等不能僅僅依賴 JWT認證,需要進行短信、指紋等二次認證
?PS:身邊有不少同事所在的項目會把JWT持久化,其實這違背了JWT的設計理念,把JWT當成傳統(tǒng)的會話ID使用了
?
JWT一般用于認證場景,搭配API網(wǎng)關使用效果甚佳。多數(shù)情況下,API網(wǎng)關會存在一些通用不需要認證的接口,其他則是需要認證JWT合法性并且提取JWT中的消息載荷內(nèi)容進行調(diào)用,針對這個場景:
對于控制器入口可以提供一個自定義注解標識特定接口需要進行 JWT認證,這個場景在Spring Cloud Gateway中需要自定義實現(xiàn)一個JWT認證的WebFilter對于單純的路由和轉(zhuǎn)發(fā)可以提供一個 URI白名單集合,命中白名單則不需要進行JWT認證,這個場景在Spring Cloud Gateway中需要自定義實現(xiàn)一個JWT認證的GlobalFilter
下面就Spring Cloud Gateway和jjwt,貼一些骨干代碼,限于篇幅不進行細節(jié)展開。引入依賴:
<dependencyManagement>
????<dependencies>
????????<dependency>
????????????<groupId>org.springframework.cloudgroupId>
????????????<artifactId>spring-cloud-dependenciesartifactId>
????????????<version>Hoxton.SR10version>
????????????<type>pomtype>
????????????<scope>importscope>
????????dependency>
????dependencies>
dependencyManagement>
<dependencies>
????<dependency>
????????<groupId>io.jsonwebtokengroupId>
????????<artifactId>jjwt-apiartifactId>
????????<version>0.11.2version>
????dependency>
????<dependency>
????????<groupId>io.jsonwebtokengroupId>
????????<artifactId>jjwt-implartifactId>
????????<version>0.11.2version>
????????<scope>runtimescope>
????dependency>
????<dependency>
????????<groupId>io.jsonwebtokengroupId>
????????<artifactId>jjwt-jacksonartifactId>
????????<version>0.11.2version>
????????<scope>runtimescope>
????dependency>
????<dependency>
????????<groupId>org.projectlombokgroupId>
????????<artifactId>lombokartifactId>
????????<version>1.18.18version>
????????<scope>providedscope>
????dependency>
????<dependency>
????????<groupId>org.springframework.cloudgroupId>
????????<artifactId>spring-cloud-starter-gatewayartifactId>
????dependency>
dependencies>
然后編寫JwtSpi和對應的實現(xiàn)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?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?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的非完全實現(xiàn):
@Component
public?class?JwtGlobalFilter?implements?GlobalFilter,?Ordered,?EnvironmentAware?{
????private?final?AntPathMatcher?pathMatcher?=?new?AntPathMatcher();
????private?List?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?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?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曾經(jīng)遇到的坑
筆者負責的API網(wǎng)關使用了JWT應用于認證場景,算法上使用了安全性稍高的RS256,使用RSA算法進行簽名生成。項目上線初期,JWT的過期時間都固定設置為7天,生產(chǎn)日志發(fā)現(xiàn)該API網(wǎng)關周期性發(fā)生"假死"現(xiàn)象,具體表現(xiàn)為:
Nginx自檢周期性出現(xiàn)自檢接口調(diào)用超時,提示部分或者全部API網(wǎng)關節(jié)點宕機API網(wǎng)關所在機器的CPU周期性飆高,在用戶訪問量低的時候表現(xiàn)平穩(wěn)通過 ELK進行日志排查,發(fā)現(xiàn)故障出現(xiàn)時段有JWT集中性過期和重新生成的日志痕跡
排查結果表明JWT集中過期和重新生成時候使用RSA算法進行簽名是CPU密集型操作,同時重新生成大量JWT會導致服務所在機器的CPU超負載工作。「初步的解決方案是」:
JWT生成的時候,過期時間添加一個隨機數(shù),例如360000(1小時的毫秒數(shù)) ~ 8640000(24小時的毫秒數(shù))之間取一個隨機值添加到當前時間戳加7天得到exp值
這個方法,對于一些老用戶營銷場景(老用戶長時間沒有登錄,他們客戶端緩存的JWT一般都已經(jīng)過期)沒有效果。有時候運營會通過營銷活動喚醒老用戶,大量老用戶重新登錄有可能出現(xiàn)爆發(fā)性大批量重新生成JWT的情況,對于這個場景提出兩個解決思路:
首次生成 JWT時候,考慮延長過期時間,但是時間越長,風險越大提升 API網(wǎng)關所在機器的硬件配置,特別是CPU配置,現(xiàn)在很多云廠商都有彈性擴容方案,可以很好應對這類突發(fā)流量場景
小結
主流的JWT方案是JWS,此方案是只編碼和簽名,不加密,務必注意這一點,JWS方案是無狀態(tài)并且不安全的,關鍵操作應該做多重認證,也要做好黑名單機制防止JWT泄漏后造成安全性問題。JWT不存儲在服務端,這既是它的優(yōu)勢,同時也是它的劣勢。很多軟件架構都無法做到盡善盡美,這個時候只能權衡利弊。
參考資料:
RFC 7519 jjwt部分源碼
(本文完 c-3-w e-a-20210219)
