前提
這是《冷飯新炒》系列的第五篇文章,
本文會翻炒一個用以產生訪問令牌的開源標準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的簽名演算法盡可能使用安全性高的演算法,如RSXXXClaims盡可能不要寫入敏感資訊高風險場景如支付操作等不能僅僅依賴
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不存盤在服務端,這既是它的優勢,同時也是它的劣勢,很多軟體架構都無法做到盡善盡美,這個時候只能權衡利弊,
參考資料:
RFC 7519
jjwt部分原始碼
(本文完 c-3-w e-a-20210219)
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/261854.html
標籤:其他
