iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
佛心分享-SideProject30

吃出一個SideProject!系列 第 19

Day 19:實作 Token 的 Refresh 機制 (1)

  • 分享至 

  • xImage
  •  

昨天我們完成 JWT 驗證功能測試,今天要嘗試實作 Token 的 Refresh 功能。
因為會在登入時同時發放 Refresh Token ,因此今天會先以建立對應 Table、新增簽發 Refresh Token 功能、修改登入 Response 這幾個步驟為主。

這幾天查看網路上許多實作分享時經常看到把 Token 存在哪的安全性問題,但這個 side project 的定位還是以最小可行性為主,我們今天還是從簡,會以將 refresh token 放在 reponse body 中回傳的方法為主,待日後功能大致完成後再來改進安全性的問題。

介紹 Refresh 機制

為什麼會需要這個功能雖然我們在前面已經提過了,在這邊還是稍微說明一下,首先定義今天會使用到的專有名詞:

  • Refresh Token
    • 定義:顧名思義是專門用來換發憑證的一種 Token。
    • 特性
      • 通常有效期限較長,效期設定通常以天為單位,1天、3天到7天都有可能。
      • 通常會存在資料庫中,因此可以被撤銷。
      • 一次性使用或有 Rotation 機制。
  • Access Token
    • 定義:一種帶著使用者身份與權限的憑證,用於存取受保護的資源。
    • 特性
      • 通常有效期限較短,大概15分鐘到30分鐘左右。
      • 不會存進資料庫,一旦簽發就無法被伺服器撤銷。
    • 我們在登入功能返還的 JWT Token 就是一種 Access Token。

先說明為什麼 Access Token 的有效期限通常要設定的較短:

  1. 安全性:如果 Access Token 被竊取,由於它很快就會過期,攻擊者能利用的時間窗口較短。
  2. 無狀態:Access Token 是無狀態的,一旦發出就無法被撤銷(除非過期)。短期有效降低了被惡意利用的風險。

但是這樣一來每次使用者的 Access Token 到期後就需要重新登入取得新的憑證,影響了使用者體驗。

Refresh 機制就是用來解決這個問題。概念上就是使用一個長期有效的 Refresh Token 來獲取新的 Access Token。可以發現 Refresh 與 Access Token 在諸多特性上是相反的,例如 Refresh Token 會存在後端,可被撤銷並僅支援一次性使用等,都與我們前面介紹 JWT 的特性比較不同,不難發現這些機制落差的背後都是為了權衡使用者體驗與安全性。

加入 Refresh 機制後的驗證流程

今天的實作大部分都參考 bezkoder,包含下面這張圖都是大神畫出來的:

https://ithelp.ithome.com.tw/upload/images/20251003/20178099rULBqbmDQZ.png

資料來源:bezkoder

上圖很好的詮釋了從使用者登入開始,Client 端與 Server 如何進行互動。加入 Refresh Token 後登入的驗證流程大概會是:

  1. 使用者透過帳號密碼登入,前端發送登入請求。
  2. 後端驗證身分後,同時回傳 Access Token 以及 Refresh Token。
  3. 前端請求存取受保護資源時,皆須攜帶 Access Token 表明身分。
  4. 後端驗證身分,若判斷 Access Token 過期,則回應過期的錯誤訊息給前端。
  5. 前端收到身分驗證錯誤,且原因是 Access Token 過期。攜帶 Refresh Token 打換 Token 的 API (/refreshToken)。
  6. 後端驗證 Refresh Token 的有效性(資料庫中確認該 Token 存在、與此使用者關聯且未過期),若有效,會回傳新的 Access Token 與 RefreshToken;若無效則強制使用者重新登入。

看的出來 Refresh 機制主要使用在 JWT 驗證失敗且原因為過期的狀況,不會需要修改太多原有的程式碼。

大致上說明完流程,我們今天先完成到第二點:在使用者成功登入後,回傳給前端 Access Token 以及 Refresh Token。

建立 RefreshTokenEntity

因為資料庫中會有一張表儲存 RefreshToken 相關資訊,因此要為其建立一個 Entity 以定義這個物件的屬性。這次我們要透過 lombok 提供的註解功能建立資料表,先在pom中加入套件:

...
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
...

在 Entity package 底下新增一個 RefreshTokenEntity 的類別,內容如下:

@Entity
@Table(name = "refresh_tokens") 
@Data
public class RefreshTokenEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 一個用戶可有多個不同的 Token (從不同裝置登入)
    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private UserEntity user;

    @Column(nullable = false, unique = true) 
    private String token;

    @Column(nullable = false)
    private Instant expiryDate; 

    public boolean isExpired() {
        return this.expiryDate.isBefore(Instant.now());
    }
}

在此並沒有建立紀錄狀態的欄位,因為 Refresh Token 本身並不具有特別的涵義,在到期後刪除也不會影響業務邏輯,直接硬刪除即可。各註解簡單說明如下:

  • @Data:Lombok 的註解,會自動為所有欄位產生 getter, setter, equals(), hashCode()toString() 方法。
  • @ManyToOne:設定 RefreshTokenUserEntity 的多對一關聯。一個使用者可以擁有多個 Refresh Token (例如,在不同裝置上登入)。optional = false 確保這個關聯的物件必須存在。
  • @JoinColumn:指定在 refresh_tokens 表中,用來關聯 users 表的外鍵欄位名為 user_id
  • Instant:時間戳的一種資料型態,不含時區,永遠代表 UTC+0 時間,易讀性不高很適合這個場景。

建立 RefreshTokenRepository

在 Repository package 底下新增 RefreshTokenRepository。在使用者登入簽發 Refresh Token 時目前只需要這個 Repository 的 save 方法。未來會需要的findById、findByUser跟deleteById,我們留在 Refresh 機制時作時會更清楚,要現在新增的話也可以,我這邊選擇先留空。

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
}

新增 RefreshTokenService

Service package 中新增 RefreshTokenService 的介面與實作類別,專門管理與 Refresh Token 相關的業務邏輯,今天先完成新增功能即可 (這邊會注入過期時間,所以記得先到設定檔中新增這個變數,我先設定為1天) 實作類別內容如下:


@Service
public class RefreshTokenService {

    @Value("${jwt.refreshTokenExpirationMs}")
    private Long refreshTokenExpirationMs; 

		@Autowired
    private RefreshTokenRepository refreshTokenRepository;
    
    public RefreshTokenEntity createRefreshToken(UserEntity user) {

        // 建立 RefreshTokenEntity
        RefreshTokenEntity refreshToken = new RefreshTokenEntity();
        refreshToken.setUser(user);
        refreshToken.setToken(UUID.randomUUID().toString()); // 產生一個隨機的 UUID 作為 token
        refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenExpirationMs)); // 設定過期時間

        return refreshTokenRepository.save(refreshToken); // 儲存並返回
    }

}

修改登入API

  1. LoginResponse
    新增 refreshToken 欄位
public record LoginResponse(
        String accessToken,
        String refreshToken,
        ...
) {
}
  1. 修改 AuthController

注入 RefreshTokenService,並在登入時使用的驗證方法中,產生新的 Refresh Token,並且加入到 Response 中。

@Autowired
private  RefreshTokenService refreshTokenService; // 注入 RefreshTokenService

public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {

    ...

	// 產生新的 Refresh Token
    RefreshTokenEntity refreshToken = refreshTokenService.createRefreshToken(userDetails);

    return ResponseEntity.ok(new LoginResponse(accessToken,
        refreshToken.getToken(), // 新增回傳 refreshToken
        userDetails.getId(),
        userDetails.getUsername(),
        roles));
}

功能測試:登入時是否收到包含 Refresh Token的回應

修改完成後,重啟服務。使用之前測試的帳號進行登入,回傳的結果如我們預期,狀態為成功 (200),並且包含了最重要的 accessToken 與 RefreshToken。

https://ithelp.ithome.com.tw/upload/images/20251003/20178099AzM4iKphMn.png


今天,我們成功在登入功能中加入 Refresh Token,完成簽發 Refresh Token 的功能,過程建立了新的Table、Service,完成了 Refresh 功能的前置作業。

明天,我們會走完流程剩下的部分,完成主要的 Refresh 功能。


上一篇
Day 18:JWT 驗證功能測試與 Filter Chain 的簡易說明
下一篇
Day 20:實作 JWT Token Refresh 機制 (2)
系列文
吃出一個SideProject!20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言