冷飯新炒:理解JWT的實現原理和基本使用

throwable發表於2021-02-20

前提

這是《冷飯新炒》系列的第五篇文章。

本文會翻炒一個用以產生訪問令牌的開源標準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 AlgorithmJSON Web演算法,數字簽名或者MAC演算法,應用於JWS的可用演算法列表如下:

總的來說,JWT其實有兩種實現,基於JWE實現的依賴於加解密演算法、BASE64URL編碼和身份認證等手段提高傳輸的Claims的被破解難度,而基於JWS的實現使用了BASE64URL編碼和數字簽名的方式對傳輸的Claims提供了完整性保護,也就是僅僅保證傳輸的Claims內容不被篡改,但是會暴露明文。目前主流的JWT框架中大部分都沒有實現JWE,所以下文主要通過JWS的實現方式進行深入探討

JWT中的Claims

Claim有索賠、聲稱、要求或者權利要求的含義,但是筆者覺得任一個翻譯都不怎麼合乎語義,這裡保留Claim關鍵字直接作為命名。JWT的核心作用就是保護Claims的完整性(或者資料加密),保證JWT傳輸的過程中Claims不被篡改(或者不被破解)。ClaimsJWT原始內容中是一個JSON格式的字串,其中單個ClaimK-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規範檔案中稱這些HeaderJOSE HeaderJOSE的全稱為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 型別,例如JWTJWS或者JWE等等
cty Content Type 內容型別,決定payload部分的MediaType

最常見的兩個Header就是algtyp,例如:

{
  "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,假設ClaimsJSON形式是:

{
   "iss": "throwx",
   "jid": 1
}

那麼扁平化非緊湊格式下的payload節點就是:

{  
   ......
   "payload": {
      "iss": "throwx",
      "jid": 1
   }
   ......
}

JWS簽名演算法

JWS簽名生成依賴於雜湊或者加解密演算法,可以使用的演算法見前面貼出的圖,例如HS256,具體是HMAC SHA-256,也就是通過雜湊演算法SHA-256對於編碼後的HeaderClaims字串進行一次雜湊計算,簽名生成的虛擬碼如下:

## 不進行編碼
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類庫,如auth0jjwt

JWT的使用場景和實戰

JWT本質是一個令牌,更多場景下是作為會話IDsession_id)使用,作用是'維持會話的粘性'和攜帶認證資訊(如果用JWT術語,應該是安全地傳遞Claims)。筆者記得很久以前使用的一種Session ID解決方案是由服務端生成和持久化Session ID,返回的Session ID需要寫入使用者的Cookie,然後使用者每次請求必須攜帶CookieSession ID會對映使用者的一些認證資訊,這一切都是由服務端管理,一個很常見的例子就是Tomcat容器中出現的J(ava)SESSIONID。與之前的方案不同,JWT是一種無狀態的令牌,它並不需要由服務端儲存,攜帶的資料或者會話的資料都不需要持久化,使用JWT只需要關注Claims的完整性和合法性即可,生成JWT時候所有有效資料已經通過編碼儲存在JWT字串中。正因JWT是無狀態的,一旦頒發後得到JWT的客戶端都可以通過它與服務端互動,JWT一旦洩露有可能造成嚴重安全問題,因此實踐的時候一般需要做幾點:

  • JWT需要設定有效期,也就是exp這個Claim必須啟用和校驗
  • JWT需要建立黑名單,一般使用jti這個Claim即可,技術上可以使用布隆過濾器加資料庫的組合(數量少的情況下簡單操作甚至可以用RedisSET資料型別)
  • 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 Gatewayjjwt,貼一些骨幹程式碼,限於篇幅不進行細節展開。引入依賴:

<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;
    }
}

然後是JwtGlobalFilterJwtWebFilter的非完全實現:

@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)

相關文章