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

前提

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

本文會翻炒一個用以產生訪問令牌的開源標準JWT,介紹JWT的規範、底層實現原理、基本使用和應用場景。java

JWT規範

很惋惜維基百科上沒有搜索到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基本概念

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 AlgorithmJSON Web算法,數字簽名或者MAC算法,應用於JWS的可用算法列表以下:

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

JWT中的Claims

Claim有索賠、聲稱、要求或者權利要求的含義,可是筆者以爲任一個翻譯都不怎麼合乎語義,這裏保留Claim關鍵字直接做爲命名。JWT的核心做用就是保護Claims的完整性(或者數據加密),保證JWT傳輸的過程當中Claims不被篡改(或者不被破解)。ClaimsJWT原始內容中是一個JSON格式的字符串,其中單個ClaimK-V結構,做爲JsonNode中的一個field-value,這裏列出經常使用的規範中預約義好的Claimshell

簡稱 全稱 含義
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)

相關文章
相關標籤/搜索