這是《冷飯新炒》系列的第五篇文章。html
本文會翻炒一個用以產生訪問令牌的開源標準JWT
,介紹JWT
的規範、底層實現原理、基本使用和應用場景。java
很惋惜維基百科上沒有搜索到JWT
的條目,可是從jwt.io
的首頁展現圖中,能夠看到描述:git
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two partiesgithub
從這段文字中能夠提取到JWT
的規範文件RFC 7519
,裏面有詳細地介紹JWT
的基本概念,Claims
的含義、佈局和算法實現等,下面逐個展開擊破。web
JWT
全稱是JSON Web Token
,若是從字面上理解感受是基於JSON
格式用於網絡傳輸的令牌。實際上,JWT
是一種緊湊的Claims
聲明格式,旨在用於空間受限的環境進行傳輸,常見的場景如HTTP
受權請求頭參數和URI
查詢參數。JWT
會把Claims
轉換成JSON
格式,而這個JSON
內容將會應用爲JWS
結構的有效載荷或者應用爲JWE
結構的(加密處理後的)原始字符串,經過消息認證碼(Message Authentication Code
或者簡稱MAC
)和/或者加密操做對Claims
進行數字簽名或者完整性保護。redis
這裏有三個概念在其餘規範文件中,簡單提一下:算法
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
的實現方式進行深刻探討。spring
Claim
有索賠、聲稱、要求或者權利要求的含義,可是筆者以爲任一個翻譯都不怎麼合乎語義,這裏保留Claim
關鍵字直接做爲命名。JWT
的核心做用就是保護Claims
的完整性(或者數據加密),保證JWT
傳輸的過程當中Claims
不被篡改(或者不被破解)。Claims
在JWT
原始內容中是一個JSON
格式的字符串,其中單個Claim
是K-V
結構,做爲JsonNode
中的一個field-value
,這裏列出經常使用的規範中預約義好的Claim
:shell
簡稱 | 全稱 | 含義 |
---|---|---|
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
爲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" }
主要介紹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
簽名生成依賴於散列或者加解密算法,可使用的算法見前面貼出的圖,例如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
的生成、解析和校驗操做。先引入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
本質是一個令牌,更多場景下是做爲會話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/*
筆者負責的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)