昨天我們完成 JWT 驗證功能測試,今天要嘗試實作 Token 的 Refresh 功能。
因為會在登入時同時發放 Refresh Token ,因此今天會先以建立對應 Table、新增簽發 Refresh Token 功能、修改登入 Response 這幾個步驟為主。
這幾天查看網路上許多實作分享時經常看到把 Token 存在哪的安全性問題,但這個 side project 的定位還是以最小可行性為主,我們今天還是從簡,會以將 refresh token 放在 reponse body 中回傳的方法為主,待日後功能大致完成後再來改進安全性的問題。
為什麼會需要這個功能雖然我們在前面已經提過了,在這邊還是稍微說明一下,首先定義今天會使用到的專有名詞:
先說明為什麼 Access Token 的有效期限通常要設定的較短:
但是這樣一來每次使用者的 Access Token 到期後就需要重新登入取得新的憑證,影響了使用者體驗。
Refresh 機制就是用來解決這個問題。概念上就是使用一個長期有效的 Refresh Token 來獲取新的 Access Token。可以發現 Refresh 與 Access Token 在諸多特性上是相反的,例如 Refresh Token 會存在後端,可被撤銷並僅支援一次性使用等,都與我們前面介紹 JWT 的特性比較不同,不難發現這些機制落差的背後都是為了權衡使用者體驗與安全性。
今天的實作大部分都參考 bezkoder,包含下面這張圖都是大神畫出來的:
資料來源:bezkoder
上圖很好的詮釋了從使用者登入開始,Client 端與 Server 如何進行互動。加入 Refresh Token 後登入的驗證流程大概會是:
看的出來 Refresh 機制主要使用在 JWT 驗證失敗且原因為過期的狀況,不會需要修改太多原有的程式碼。
大致上說明完流程,我們今天先完成到第二點:在使用者成功登入後,回傳給前端 Access Token 以及 Refresh Token。
因為資料庫中會有一張表儲存 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
:設定 RefreshToken
與 UserEntity
的多對一關聯。一個使用者可以擁有多個 Refresh Token (例如,在不同裝置上登入)。optional = false
確保這個關聯的物件必須存在。@JoinColumn
:指定在 refresh_tokens
表中,用來關聯 users
表的外鍵欄位名為 user_id
。Instant
:時間戳的一種資料型態,不含時區,永遠代表 UTC+0 時間,易讀性不高很適合這個場景。在 Repository package 底下新增 RefreshTokenRepository。在使用者登入簽發 Refresh Token 時目前只需要這個 Repository 的 save 方法。未來會需要的findById、findByUser跟deleteById,我們留在 Refresh 機制時作時會更清楚,要現在新增的話也可以,我這邊選擇先留空。
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
}
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); // 儲存並返回
}
}
refreshToken
欄位public record LoginResponse(
String accessToken,
String refreshToken,
...
) {
}
注入 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));
}
修改完成後,重啟服務。使用之前測試的帳號進行登入,回傳的結果如我們預期,狀態為成功 (200),並且包含了最重要的 accessToken 與 RefreshToken。
今天,我們成功在登入功能中加入 Refresh Token,完成簽發 Refresh Token 的功能,過程建立了新的Table、Service,完成了 Refresh 功能的前置作業。
明天,我們會走完流程剩下的部分,完成主要的 Refresh 功能。