昨天我們完成了約 80% 登入功能的實作,就差準備好要回傳的 JWT Token 我們就可以進入功能測試啦~
因為 JWT 的簽發需要金鑰,因此今天會從金鑰產生、環境變數設定開始介紹,直到完成產生 JWT Token 的功能為止。
我們選擇以非對稱加密的方式來為我們的 JWT 進行簽署,因此先使用 openssl 來產生一對公鑰與私鑰:
# 生成 2048 位元的 RSA 私鑰
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
# 從私鑰中提取公鑰
openssl pkey -in private_key.pem -pubout -out public_key.pem
我們以RSA演算法產生金鑰對,今天在簽發 JWT 時會用到私鑰,公鑰則是在之後驗證 JWT 的環節會使用。
若產生公、私鑰的檔案放在專案資料夾內,記得加入.gitignore,不要上傳到版控了。
這組金鑰對不應該在每次重啟服務時產生,而是始終保持同一組金鑰對,因此這邊想透過 docker-compose 啟動時,傳入這個變數。
以下指令會輸出不換行的金鑰內容,以便我們正確放入 .env
中:
# bash
# 查看私鑰
grep -v '^-' private_key.pem | tr -d '\n'
# 查看公鑰
grep -v '^-' public_key.pem | tr -d '\n'
將產生的公、私鑰複製貼上 .env
的檔案中:
...
JWT_PRIVATE_KEY="YOUR_JWT_PRIVATE_KEY"
JWT_PUBLIC_KEY="YOUR_JWT_PUBLIC_KEY"
...
在 docker-compose.yml
的 auth-service 下新增以下內容:
...
auth-service:
...
environment:
...
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
...
傳入之後,到設定檔(application.properties
)加入環境變數,以便在程式中讀取該變數內容:
jwt.private-key=${JWT_PRIVATE_KEY}
jwt.public-key=${JWT_PUBLIC_KEY}
# jwt 時效,本例設定約半小時
jwt.expiration-ms=1800000
先到 pom 中加入 JWT 相關套件:
...
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
...
jjwt-api
:提供了 JWT 的核心介面與 Builder。jjwt-impl
:提供了 JWT 的具體實作。jjwt-jackson
:負責將 JWT 的 Payload 部分序列化成 JSON。新增一個 utils package 用來存放類似用途的類別。新增JwtUtils
類別
@Component
public class JwtUtils {
// 取得設定檔變數內容
@Value("${jwt.expiration-ms}")
private int jwtExpirationMs;
@Value("${jwt.private-key}")
private String privateKeyString;
private PrivateKey privateKey;
// Bean 實例化後,加載私鑰
@PostConstruct
public void init() {
try {
byte[] privateKeyBytes = Decoders.BASE64.decode(privateKeyString);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
this.privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes));
} catch (Exception e) {
throw new RuntimeException("Error loading RSA keys during JwtUtils initialization.", e);
}
}
public String generateJwtToken(Authentication authentication) {
UserEntity userPrincipal = (UserEntity) authentication.getPrincipal();
List<String> roles = userPrincipal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
return Jwts.builder()
.header().add("typ","JWT").and()
.subject(userPrincipal.getId().toString())
.claim("roles", roles)
.issuer("auth-service")
.issuedAt(new Date())
.expiration(new Date((new Date()).getTime() + jwtExpirationMs))
.signWith(privateKey, Jwts.SIG.RS512)
.compact();
}
}
@Value
是 Spring 用來注入設定值的註解。會去尋找application.properties
中對應 ${...}
名稱的內容。@PostConstruct
:在所有依賴注入完成之後,Spring 容器會查找並執行標記有 @PostConstruct
註解的方法,如此一來可以確認金鑰從設定檔被注入後才進行解碼。catch
的是 getInstance
方法拋出的 NoSuchAlgorithmException
以及 generatePrivate
方法拋出的 InvalidKeySpecException
,兩者皆為Checked Exception。Authentication
** ,這是從 AuthenticationManager
認證成功後回傳的物件,包含了當前登入的完整使用者資訊。.getPrincipal()
:指的就是由我們的 UserDetailsServiceImpl
所回傳的 UserDetails
物件。userPrincipal.getAuthorities().stream().map(...).toList()
:回傳使用者所有權限的物件集合,最後轉換成字串陣列。Jwts.builder()
:這裡是使用 jjwt
函式庫的 Builder 模式來建立 JWT。底下幾乎都是 JWT 本身定義的 Claim 項目,也就是會被放入 payload 編碼的內容:
subject
:JWT 所面向的用戶,就是使用者,通常會放使用者的唯一識別碼。簡寫為sub
。issuer
:此 JWT 的發行人,在此我先定義為本服務,簡寫為iss
issuedAt
:JWT 創建時間,簡寫為 iat
expiration
:JWT 過期時間,簡寫為 exp
claim
:最後如果有想自定義的 payload,可以呼叫 claim
方法以 key-value 的方式帶入。header
:設定 header 內容。透過 add
以 key-value 的方式加入 JWT Token Type 的資訊。.signWith()
:使用我們的私鑰,對 Header 和 Payload 進行數位簽章,產生 Signature。.compact()
:將 Header, Payload, Signature 這三部分,分別進行 Base64Url 編碼,並串接起來產生最終的 JWT 字串。至此,我們的登入功能應該可以順利運行,明天就來測試看看不同的情境下回應是否都如我們預期~
為了深入理解 JWT ,看了很多不同概念或領域的文章像是 JWS/JWE,或密碼學、網路通訊等概念的議題。過程中還有很多內容沒有被我整理進來,因為覺得自己理解的還不是很透徹QQ,希望有朝一日另闢一篇文章來整理這些吸收到的內容。如果今天的內容有任何問題,還請不吝告知,感謝看到這裡的各位!