手把手教你實現JWT Token


640?wx_fmt=gif

1. 前言

Json Web Token (JWT) 近幾年是前后端分離常用的 Token 技術,是目前最流行的跨域身份驗證解決方案。你可以通過文章 JWT。今天我們來手寫一個通用的 JWT 服務。DEMO 獲取方式在文末,實現在 jwt 相關包下

2. spring-security-jwt

spring-security-jwt 是 Spring Security Crypto 提供的 JWT 工具包 。

  <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-jwt</artifactId>
        <version>${spring-security-jwt.version}</version>
  </dependency>

核心類只有一個: org.springframework.security.jwt.JwtHelper 。它提供了兩個非常有用的靜態方法。

3. JWT 編碼

JwtHelper 提供的第一個靜態方法就是 encode(CharSequence content, Signer signer) 這個是用來生成jwt的方法 需要指定 payload 跟 signer 簽名算法。payload 存放了一些可用的不敏感信息:

  • iss jwt簽發者

  • sub jwt所面向的用戶

  • aud 接收jwt的一方

  • iat jwt的簽發時間

  • exp jwt的過期時間,這個過期時間必須要大於簽發時間 iat

  • jti jwt的唯一身份標識,主要用來作為一次性token,從而回避重放攻擊

除了以上提供的基本信息外,我們可以定義一些我們需要傳遞的信息,比如目標用戶的權限集 等等。切記不要傳遞密碼等敏感信息 ,因為 JWT 的前兩段都是用了 BASE64 編碼,幾乎算是明文了。

3.1 構建 JWT 中的 payload

我們先來構建 payload :

 /**
  * 構建 jwt payload
  *
  * @author Felordcn
  * @since 11:27 2019/10/25
  **/
 public class JwtPayloadBuilder {

     private Map<String, String> payload = new HashMap<>();
     /**
      * 附加的屬性
      */
     private Map<String, String> additional;
     /**
      * jwt簽發者
      **/
     private String iss;
     /**
      * jwt所面向的用戶
      **/
     private String sub;
     /**
      * 接收jwt的一方
      **/
     private String aud;
     /**
      * jwt的過期時間,這個過期時間必須要大於簽發時間
      **/
     private LocalDateTime exp;
     /**
      * jwt的簽發時間
      **/
     private LocalDateTime iat = LocalDateTime.now();
     /**
      * 權限集
      */
     private Set<String> roles = new HashSet<>();
     /**
      * jwt的唯一身份標識,主要用來作為一次性token,從而回避重放攻擊
      **/
     private String jti = IdUtil.simpleUUID();

     public JwtPayloadBuilder iss(String iss) {
         this.iss = iss;
         return this;
     }


     public JwtPayloadBuilder sub(String sub) {
         this.sub = sub;
         return this;
     }

     public JwtPayloadBuilder aud(String aud) {
         this.aud = aud;
         return this;
     }


     public JwtPayloadBuilder roles(Set<String> roles) {
         this.roles = roles;
         return this;
     }

     public JwtPayloadBuilder expDays(int days) {
         Assert.isTrue(days > 0, "jwt expireDate must after now");
         this.exp = this.iat.plusDays(days);
         return this;
     }

     public JwtPayloadBuilder additional(Map<String, String> additional) {
         this.additional = additional;
         return this;
     }

     public String builder() {
         payload.put("iss", this.iss);
         payload.put("sub", this.sub);
         payload.put("aud", this.aud);
         payload.put("exp", this.exp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
         payload.put("iat", this.iat.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
         payload.put("jti", this.jti);

         if (!CollectionUtils.isEmpty(additional)) {
             payload.putAll(additional);
         }
         payload.put("roles", JSONUtil.toJsonStr(this.roles));
         return JSONUtil.toJsonStr(JSONUtil.parse(payload));

     }

 }

通過建造類 JwtClaimsBuilder 我們可以很方便來構建 JWT 所需要的 payload json 字符串傳遞給 encode(CharSequence content, Signer signer) 中的 content 。

3.2 生成 RSA 密鑰並進行簽名

為了生成 JWT Token 我們還需要使用 RSA 算法來進行簽名。這里我們使用 JDK 提供的證書管理工具 Keytool 來生成 RSA 證書 ,格式為 jks 格式。

生成證書命令參考:

keytool -genkey -alias felordcn -keypass felordcn -keyalg RSA -storetype PKCS12 -keysize 1024 -validity 365 -keystore d:/keystores/felordcn.jks -storepass 123456 -dname "CN=(Felord), OU=(felordcn), O=(felordcn), L=(zz), ST=(hn), C=(cn)"

 
其中  -alias felordcn -storepass 123456  我們要作為配置使用要記下來。我們要使用下面定義的這個類來讀取證書

 
 package cn.felord.spring.security.jwt;

 import org.springframework.core.io.ClassPathResource;

 import java.security.KeyFactory;
 import java.security.KeyPair;
 import java.security.KeyStore;
 import java.security.PublicKey;
 import java.security.interfaces.RSAPrivateCrtKey;
 import java.security.spec.RSAPublicKeySpec;

 /**
  * KeyPairFactory
  *
  * @author Felordcn
  * @since 13:41 2019/10/25
  **/
 class KeyPairFactory {

     private KeyStore store;

     private final Object lock = new Object();

     /**
      * 獲取公私鑰.
      *
      * @param keyPath  jks 文件在 resources 下的classpath
      * @param keyAlias  keytool 生成的 -alias 值  felordcn
      * @param keyPass  keytool 生成的  -storepass 值  123456
      * @return the key pair 公私鑰對
      */
    KeyPair create(String keyPath, String keyAlias, String keyPass) {
         ClassPathResource resource = new ClassPathResource(keyPath);
         char[] pem = keyPass.toCharArray();
         try {
             synchronized (lock) {
                 if (store == null) {
                     synchronized (lock) {
                         store = KeyStore.getInstance("jks");
                         store.load(resource.getInputStream(), pem);
                     }
                 }
             }
             RSAPrivateCrtKey key = (RSAPrivateCrtKey) store.getKey(keyAlias, pem);
             RSAPublicKeySpec spec = new RSAPublicKeySpec(key.getModulus(), key.getPublicExponent());
             PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(spec);
             return new KeyPair(publicKey, key);
         } catch (Exception e) {
             throw new IllegalStateException("Cannot load keys from store: " + resource, e);
         }

     }
 }

獲取了 KeyPair 就能獲取公私鑰 生成 Jwt 的兩個要素就完成了。我們可以和之前定義的 JwtPayloadBuilder 一起封裝出生成 Jwt Token 的方法:

     private String jwtToken(String aud, int exp, Set<String> roles, Map<String, String> additional) {
         String payload = jwtPayloadBuilder
                 .iss(jwtProperties.getIss())
                 .sub(jwtProperties.getSub())
                 .aud(aud)
                 .additional(additional)
                 .roles(roles)
                 .expDays(exp)
                 .builder();
         RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

         RsaSigner signer = new RsaSigner(privateKey);
         return JwtHelper.encode(payload, signer).getEncoded();
     }

通常情況下 Jwt Token 都是成對出現的,一個為平常請求攜帶的 accessToken, 另一個只作為刷新 accessToken 之用的 refreshToken 。而且 refreshToken 的過期時間要相對長一些。當 accessToken 失效而refreshToken 有效時,我們可以通過 refreshToken 來獲取新的 Jwt Token對 ;當兩個都失效就用戶就必須重新登錄了。

生成 Jwt Token對 的方法如下:

     public JwtTokenPair jwtTokenPair(String aud, Set<String> roles, Map<String, String> additional) {
         String accessToken = jwtToken(aud, jwtProperties.getAccessExpDays(), roles, additional);
         String refreshToken = jwtToken(aud, jwtProperties.getRefreshExpDays(), roles, additional);

         JwtTokenPair jwtTokenPair = new JwtTokenPair();
         jwtTokenPair.setAccessToken(accessToken);
         jwtTokenPair.setRefreshToken(refreshToken);
         // 放入緩存
         jwtTokenStorage.put(jwtTokenPair, aud);
         return jwtTokenPair;
     }

通常 Jwt Token對 會在返回給前台的同時放入緩存中。過期策略你可以選擇分開處理,也可以選擇以refreshToken 的過期時間為准。

4. JWT 解碼以及驗證

JwtHelper 提供的第二個靜態方法是Jwt decodeAndVerify(String token, SignatureVerifier verifier) 用來 驗證和解碼 Jwt Token 。我們獲取到請求中的token后會解析出用戶的一些信息。通過這些信息去緩存中對應的token ,然后比對並驗證是否有效(包括是否過期)。

      /**
       * 解碼 並校驗簽名 過期不予解析
       *
       * @param jwtToken the jwt token
       * @return the jwt claims
       */
      public JSONObject decodeAndVerify(String jwtToken) {
          Assert.hasText(jwtToken, "jwt token must not be bank");
          RSAPublicKey rsaPublicKey = (RSAPublicKey) this.keyPair.getPublic();
          SignatureVerifier rsaVerifier = new RsaVerifier(rsaPublicKey);
          Jwt jwt = JwtHelper.decodeAndVerify(jwtToken, rsaVerifier);
          String claims = jwt.getClaims();
          JSONObject jsonObject = JSONUtil.parseObj(claims);
          String exp = jsonObject.getStr(JWT_EXP_KEY);
         // 是否過期
          if (isExpired(exp)) {
              throw new IllegalStateException("jwt token is expired");
          }
          return jsonObject;
      }

上面我們將有效的 Jwt Token 中的 payload 解析為 JSON對象 ,方便后續的操作。

5. 配置

我們將 JWT 的可配置項抽出來放入 JwtProperties 如下:

 /**
  * Jwt 在 springboot application.yml 中的配置文件
  *
  * @author Felordcn
  * @since 15 :06 2019/10/25
  */
 @Data
 @ConfigurationProperties(prefix=JWT_PREFIX)
 public class JwtProperties {
     static final String JWT_PREFIX= "jwt.config";
     /**
      * 是否可用
      */
     private boolean enabled;
     /**
      * jks 路徑
      */
     private String keyLocation;
     /**
      * key alias
      */
     private String keyAlias;
     /**
      * key store pass
      */
     private String keyPass;
     /**
      * jwt簽發者
      **/
     private String iss;
     /**
      * jwt所面向的用戶
      **/
     private String sub;
     /**
      * access jwt token 有效天數
      */
     private int accessExpDays;
     /**
      * refresh jwt token 有效天數
      */
     private int refreshExpDays;
 }

然后我們就可以配置 JWT 的 javaConfig 如下:

 /**
  * JwtConfiguration
  *
  * @author Felordcn
  * @since 16 :54 2019/10/25
  */
 @EnableConfigurationProperties(JwtProperties.class)
 @ConditionalOnProperty(prefix = "jwt.config",name = "enabled")
 @Configuration
 public class JwtConfiguration {


     /**
      * Jwt token storage .
      *
      * @return the jwt token storage
      */
     @Bean
     public JwtTokenStorage jwtTokenStorage() {
         return new JwtTokenCacheStorage();
     }


     /**
      * Jwt token generator.
      *
      * @param jwtTokenStorage the jwt token storage
      * @param jwtProperties   the jwt properties
      * @return the jwt token generator
      */
     @Bean
     public JwtTokenGenerator jwtTokenGenerator(JwtTokenStorage jwtTokenStorage, JwtProperties jwtProperties) {
         return new JwtTokenGenerator(jwtTokenStorage, jwtProperties);
     }

 }

然后你就可以通過 JwtTokenGenerator 編碼/解碼驗證 Jwt Token 對 ,通過 JwtTokenStorage 來處理 Jwt Token 緩存。緩存這里我用了Spring Cache Ehcache 來實現,你也可以切換到 Redis 。相關單元測試參見 DEMO

6. 總結

今天我們利用 spring-security-jwt 手寫了一套 JWT 邏輯。無論對你后續結合 Spring Security 還是 Shiro 都十分有借鑒意義。下一篇我們會講解 JWT 結合Spring Security ,敬請關注公眾號:Felordcn 來及時獲取資料。

本次的 DEMO 可通過關注公眾號回復 day05 獲取。

 

640?wx_fmt=png

640?wx_fmt=gif


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM