主頁 >  其他 > 冷飯新炒:理解JWT的實作原理和基本使用

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

2021-02-21 12:13:23 其他

前提

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

本文會翻炒一個用以產生訪問令牌的開源標準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

簡稱全稱含義
issIssuer發行方
subSubject主體
audAudience(接收)目標方
expExpiration Time過期時間
nbfNot Before早于該定義的時間的JWT不能被接受處理
iatIssued AtJWT發行時的時間戳
jtiJWT IDJWT的唯一標識

這些預定義的Claim并不要求強制使用,何時選用何種Claim完全由使用者決定,而為了使JWT更加緊湊,這些Claim都使用了簡短的命名方式去定義,在不和內建的Claim沖突的前提下,使用者可以自定義新的公共Claim,如:

簡稱全稱含義
cidCustomer ID客戶ID
ridRole ID角色ID

一定要注意,在JWS實作中,Claims會作為payload部分進行BASE64編碼,明文會直接暴露,敏感資訊一般不應該設計為一個自定義Claim

JWT中的Header

JWT規范檔案中稱這些HeaderJOSE HeaderJOSE的全稱為Javascript Object Signature Encryption,也就是Javascript物件簽名和加密框架,JOSE Header其實就是Javascript物件簽名和加密的頭部引數,「下面列舉一下JWS中常用的Header

簡稱全稱含義
algAlgorithm用于保護JWS的加解密演算法
jkuJWK Set URL一組JSON編碼的公共密鑰的URL,其中一個是用于對JWS進行數字簽名的密鑰
jwkJSON Web Key用于對JWS進行數字簽名的密鑰相對應的公共密鑰
kidKey ID用于保護JWS進的密鑰
x5uX.509 URLX.509相關
x5cX.509 Certificate ChainX.509相關
x5tX.509 Certificate SHA-1 ThumbprinX.509相關
x5t#S256X.509 Certificate SHA-256 ThumbprintX.509相關
typType型別,例如JWTJWS或者JWE等等
ctyContent 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不存盤在服務端,這既是它的優勢,同時也是它的劣勢,很多軟體架構都無法做到盡善盡美,這個時候只能權衡利弊,

參考資料:

  • RFC 7519

  • jjwt部分原始碼

(本文完 c-3-w e-a-20210219)

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/261854.html

標籤:其他

上一篇:等保測評高風險判定——第三章 區域邊界篇

下一篇:【SRP協議】The Secure Remote Password Protocol論文筆記

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more