Java - JSON Web Tokens (JWT) 示範

上次介紹了 Golang 的 JWT 程式庫,這次介紹 Java 的 JWT 程式庫,之後再來發 JWT 結合 Spring Security 在 RESTful API 的應用!

如何選擇好的 JWT 庫

事實上,JWT 官網首頁就有提供許多程式語言開源的 JWT 程式庫,還很貼心地列出該程式庫有提供哪些功能,例如實作了哪些 JWT 加密演算法及在驗證 JWT 上有提供哪些檢查。此外,還提供了 GitHub 網址及 Star 數量,畢竟開源庫的 Star 數量可以保證一定的品質。

這次我們選擇 JJWT:

這是一個比較知名的 Java JWT 庫,在上面可以看到它提供了哪些演算法跟 check 的支援。此次示範就使用該程式庫。

先說用完的感想,我覺得很好用!文件清楚,核發跟驗證 JWT 的流程也很直覺。

JJWT 程式庫示範

這次用一個純 Java 專案來示範即可,不涉及 RESTful API 的設計。

專案名稱:jwt-demo

Note:因為程式庫只提供 maven、grandle 的方式來安裝程式庫,因此建議用 maven 及 grandle 的方式創立專案,此次示範是用 maven。

建立一個 Main.java,直接來看我寫的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import io.github.cdimascio.dotenv.Dotenv;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import java.security.Key;
import java.security.KeyPair;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;

public class Main {

public static void main(String[] args) {
// load environment variables
Dotenv dotenv = Dotenv.load();

// JWT using HS256 Alg demo
// expect jwt lifetime 2 hours
Claims claims = Jwts.claims();
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR, 2);
Date exp = calendar.getTime();
claims.setExpiration(exp);

// put userId to payload
UUID uuid = UUID.randomUUID();
String userId = uuid.toString();
claims.put("userId", userId);

// get secret key from environment
String k = dotenv.get("HS256_KEY");
Key secretKey = Keys.hmacShaKeyFor(k.getBytes());

String token = Jwts.builder().setClaims(claims).signWith(secretKey).compact();
System.out.printf("JWT HS256 token: %s\n", token);

// verify token
try {
Jws<Claims> data = Jwts.parser().require("userId", userId).setSigningKey(secretKey).parseClaimsJws(token);
Claims info = data.getBody();
// get userId from token
System.out.printf("userId: %s\n", info.get("userId"));
} catch (MalformedJwtException e) {
System.out.printf("malformed token error: %s\n", e.getMessage());
} catch (SignatureException e) {
System.out.printf("secret key is not right error: %s\n", e.getMessage());
} catch (UnsupportedJwtException e) {
System.out.printf("alg is not right error: %s\n", e.getMessage());
} catch (MissingClaimException e) {
System.out.printf("missing some claim field error: %s\n", e.getMessage());
} catch (IncorrectClaimException e) {
System.out.printf("claim field value is not right: %s\n", e.getMessage());
}

// JWT using RS256 Alg demo
KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256);
token = Jwts.builder().claim("userId", userId).signWith(keyPair.getPrivate()).compact();
System.out.printf("JWT RSA256 token: %s\n", token);
System.out.printf("userId: %s\n", Jwts.parser().setSigningKey(keyPair.getPublic()).parseClaimsJws(token).getBody().get("userId"));
}
}

基本上示範了如何進行有效的核發及將收到 token 進行驗證,採用的演算法分別有 HS256、RS256。

在開始學習之前先安裝需要的程式庫:

  • JWT 程式庫

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.10.7</version>
    </dependency>
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.10.7</version>
    <scope>runtime</scope>
    </dependency>
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.10.7</version>
    <scope>runtime</scope>
    </dependency>
  • 讀取環境變數的程式庫

    1
    2
    3
    4
    5
    <dependency>
    <groupId>io.github.cdimascio</groupId>
    <artifactId>java-dotenv</artifactId>
    <version>5.1.3</version>
    </dependency>

來一一解釋每段 code 的作用~

JWT HS256 示範

讀取環境變數

1
2
// load environment variables
Dotenv dotenv = Dotenv.load();

這個方式是利用了外部程式庫:https://github.com/cdimascio/java-dotenv

詳細操作可以參考官方文件,這種方式簡單來說,就是在 maven 的專案目錄下的 resource 裡面新增一個檔案叫 **.env**

裡面定義你需要的環境變數,而以上的程式碼就是進行讀取,之後就可以透過:

1
dotenv.get("name")

來讀取環境變數的值。

因為這個示範是用 HS256 演算法,因此需要在.env 檔案定義ㄧ個 secret key:

1
HS256_KEY=secretCanNotTellAnyOneAndAnyBody

Note:由於 JJWT 程式庫要求安全的原因,如果採用 HS256,而你定義的 KEY 沒有達到 256 位長,也就是要 32 個字節,在簽署的時候會自動報錯,我認為這樣的設計很好。因此這邊可以看到我定義的 KEY 有到 32 個字節。

建立 claim

1
2
3
4
5
6
7
8
9
10
11
12
// JWT using HS256 Alg demo
// expect jwt lifetime 2 hours
Claims claims = Jwts.claims();
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR, 2);
Date exp = calendar.getTime();
claims.setExpiration(exp);

// put userId to payload
UUID uuid = UUID.randomUUID();
String userId = uuid.toString();
claims.put("userId", userId);

透過宣告ㄧ個 Claims 物件,透過 set 函式,可以設定 JWT 標準 claims 裡面該有的欄位,如 exp。如果要丟進去特定欄位跟值,要利用 put 函式。因為 Claims 物件本質是一個 map 物件。

這邊採用 Java 原生的 UUID 程式庫,產生 version 4 的 uuid 當作 userId,並丟進去 claims 裡面。

讀取 secret key 並且進行 JWT 核發

1
2
3
4
5
6
// get secret key from environment
String k = dotenv.get("HS256_KEY");
Key secretKey = Keys.hmacShaKeyFor(k.getBytes());

String token = Jwts.builder().setClaims(claims).signWith(secretKey).compact();
System.out.printf("JWT HS256 token: %s\n", token);

透過建立一個 Key 物件,然後利用 hamcShaKeyFor 函式,記得,裡面接受的型態必須是 byte []。

一切就緒後,就可以透過類似 chain 函式設計的方式,ㄧ連串的建立合法的 JWT。

Note:

  1. setClaims 此函式是必要的,不能不寫,否則會報錯,意思是希望你 Claims 裡面一定要定義 JWT 標準欄位的值,如基本的 exp。
  2. signWith 也是必寫,裡面要放的是我們的 KEY。
  3. 最後透過 compact () 進行合併,得知合法的 JWT。

驗證 JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 // verify token
try {
Jws<Claims> data = Jwts.parser().require("userId", userId).setSigningKey(secretKey).parseClaimsJws(token);
Claims info = data.getBody();
// get userId from token
System.out.printf("userId: %s\n", info.get("userId"));
} catch (MalformedJwtException e) {
System.out.printf("malformed token error: %s\n", e.getMessage());
} catch (SignatureException e) {
System.out.printf("secret key is not right error: %s\n", e.getMessage());
} catch (UnsupportedJwtException e) {
System.out.printf("alg is not right error: %s\n", e.getMessage());
} catch (MissingClaimException e) {
System.out.printf("missing some claim field error: %s\n", e.getMessage());
} catch (IncorrectClaimException e) {
System.out.printf("claim field value is not right: %s\n", e.getMessage());
}

用剛剛核發的 token 進行驗證,固定流程就是透過:

Jwts.parser().setSigningKey(key).parseClaimsJws(token)

相信流程很清楚,記住 require 函式,不是必須的。

require 函式作用:可用來檢查 claims 中特定欄位及值,JWT 的標準欄位也可以。

而如果沒 catch 任何 exception 就代表 token 是合法的,否則就會進入各自的 exception 進行處理。

總共有這幾種錯誤:

  1. MalformedJwtException

    畸形的 token,可能長度不足等原因

  2. SignatureException

    也就是說拿去驗證 token 的 key 跟當初簽署的 key 是不一樣的,因此會錯

  3. UnsupportedJwtException

    token 裡面的 header 欄位的 alg 不是採用 HS256,因此報錯

  4. MissingClaimException

    Claim 裡面缺少了ㄧ些欄位,因此報錯

  5. IncorrectClaimException

    Claim 裡面的一些欄位的值不符合特定的值。

主要就是第四跟第五的錯誤是根據 require () 來抓的,因此如果沒有設定 require 的話,基本上就不會進入第四跟第五的 exception。

JWT RS256 示範

1
2
3
4
5
 // JWT using RS256 Alg demo
KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256);
token = Jwts.builder().claim("userId", userId).signWith(keyPair.getPrivate()).compact();
System.out.printf("JWT RSA256 token: %s\n", token);
System.out.printf("userId: %s\n", Jwts.parser().setSigningKey(keyPair.getPublic()).parseClaimsJws(token).getBody().get("userId"));

在這邊就直接講程式碼,除了前面是設定 claims,最重要的是簽署這邊,因為 RS256 算法,是非對稱的,會需要一個 private key 跟 public key,而 private key 是用來簽署的,public key 是用來驗證 token 的。

因此 JJWT 函式庫內建有函式可以幫我們建立 private key、public key,透過 KeyPair 物件,並且選擇對應的演算法。

不過還是建議 private key 應該要用讀取環境變數的方式去設定,而不是在程式裡面產生。

總結

jjwt 是個老牌的程式庫,文件寫得很清楚,這次練習我並沒有花費很多時間去理解,核發跟驗證的 chain 的設計使用起來更加順手,而且內建還有ㄧ些安全的檢查非常的不錯。

下一次帶來 Spring Security 結合 JJWT 來設計 RESTful API 的文章~

最後最後!請聽我一言!

如果你還沒有註冊 Like Coin,你可以在文章最下方看到 Like 的按鈕,點下去後即可申請帳號,透過申請帳號後可以幫我的文章按下 Like,而 Like 最多可以點五次,而你不用付出任何一塊錢,就能給我寫這篇文章的最大的回饋!