iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
佛心分享-SideProject30

吃出一個SideProject!系列 第 14

Auth Service - 實作登入功能 (2)

  • 分享至 

  • xImage
  •  

昨天我們完成了約 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。

JwtUtils

新增一個 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();
    }

}
  1. 取得設定檔變數內容
    • @Value 是 Spring 用來注入設定值的註解。會去尋找application.properties 中對應 ${...} 名稱的內容。
  2. Bean 實例化後,加載私鑰
    • @PostConstruct:在所有依賴注入完成之後,Spring 容器會查找並執行標記有 @PostConstruct 註解的方法,如此一來可以確認金鑰從設定檔被注入後才進行解碼。
    • try-catch:此處 catch 的是 getInstance方法拋出的 NoSuchAlgorithmException 以及 generatePrivate 方法拋出的 InvalidKeySpecException,兩者皆為Checked Exception。
      若throws Exceptions 則調用的方法需要捕捉錯誤,但此方法由 Spring 框架自動調用。因此在此我們將 Exception (包含Checked Exception 與 Runtime Exception)都包成 RuntimeException,好讓我們可以在程式運行時也可以及時發現這個錯誤進行處理。
  3. generateJwtToken 方法
    • 傳入參數:此方法會傳入認證成功物件**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,希望有朝一日另闢一篇文章來整理這些吸收到的內容。如果今天的內容有任何問題,還請不吝告知,感謝看到這裡的各位!


上一篇
Day 13:Auth Service-實作登入功能 (1)
下一篇
Day15:Auth Service - 登入功能測試與除錯
系列文
吃出一個SideProject!16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言