iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
佛心分享-SideProject30

吃出一個SideProject!系列 第 20

Day 20:實作 JWT Token Refresh 機制 (2)

  • 分享至 

  • xImage
  •  

接續昨天我們在登入回應中加入 Refresh Token,確認了前端可以在登入成功後同時接收到兩組(TokenAccess Token 與 Refresh Token)

今天要來接續實作 Token 換 Token 的 Refresh 機制了~~

RefreshTokenRepository

因為 Refresh 過程中我們會去資料庫驗證有沒有這組 Token,以及這組 Token 有沒有過期(如果過期資料表中需要刪除這筆資料),因此到 RefreshTokenRepository 中加入以下兩個方法:

public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
    Optional<RefreshTokenEntity> findByToken(String token);
    void deleteByToken(String token);
}

RefreshTokenService

RefreshTokenService 中我們新增 refreshTokens 方法,為了達成 Refresh Token 一次性使用的特性,在這個方法中會檢查 Token 是否存在資料庫中且是否未過期,若通過檢查則新增一筆 Token 資料,並移除舊 Token 資訊。因為方法內牽涉到新增與刪除,此處加上 @Transactional 確認交易執行的完整性:

@Service
public class RefreshTokenServiceImpl implements RefreshTokenService {

		...

    @Transactional
    public RefreshResponse refreshTokens(String oldTokenString) {
        // 1. 查找並驗證舊的 Refresh Token
        RefreshTokenEntity oldTokenEntity = findByToken(oldTokenString)
                .orElseThrow(() -> new TokenRefreshException(ErrorCode.REFRESH_TOKEN_NOT_FOUND, "Refresh token is not in database!"));

        if (oldTokenEntity.isExpired()){
            refreshTokenRepository.deleteByToken(oldTokenEntity.getToken());
            throw new TokenRefreshException(ErrorCode.REFRESH_TOKEN_EXPIRED, "Refresh token was expired!");
        }

        // 2. 取得關聯的使用者
        UserEntity user = oldTokenEntity.getUser();

        // 3. 刪除舊的 Refresh Token
        refreshTokenRepository.delete(oldTokenEntity);

        // 4. 建立新的 Access Token 和 Refresh Token
        String newAccessToken = jwtUtils.generateJwtToken(user); 
        RefreshTokenEntity newRefreshToken = createRefreshToken(user);

        return new RefreshResponse(newAccessToken, newRefreshToken.getToken());
    }
}

(generateJwtToken 我調整成傳入 UserEntity 進行 JWT Token 的建立,所以會跟前面幾天的呼叫方式有點不太一樣。很簡單的修改就不附上程式碼了)

新增 Refresh API 端點與相關 Dto

分別建立 RefreshRequest 與 RefreshResponse 兩個類別來定義這支 API 傳入跟傳出的參數型態:

// RefreshRequest
public record RefreshRequest(
        @NotBlank(message = "refreshToken can not be blank")
        String refreshToken
) {}

// RefreshResponse
public record RefreshResponse(
        String accessToken,
        String refreshToken
){ }

在現有的 AuthController 中新增 refresh API 端點,呼叫我們剛剛新增的 refreshTokens 方法:

@RestController
@RequestMapping("/users")
public class AuthController {

    @Autowired
    private RefreshTokenService refreshTokenService;

    @PostMapping("/refresh")
    public ResponseEntity<?> refresh(@Valid @RequestBody RefreshRequest refreshRequest) {
        RefreshResponse response = refreshTokenService.refreshTokens(refreshRequest.refreshToken());
        return ResponseEntity.ok(response);
    }
}

(這次寫完也整理了 Controller 層其他方法的內容,先前把太多業務邏輯寫在這層,一樣僅是重構不影響功能,就不附上程式碼~)

為 Refresh 功能自訂 Exception 與 ExceptionHandler

為了讓前端更清楚換發時發生的錯誤,決定自訂 Exception 加上 ExceptionHandler 來處理換發時的錯誤回應。

定義 ErrorCode Enum

新增一個存放 enum 的 package ,定義一個 ErrorCode enum ,宣告我們可能會在回應中包含的 ErrorCode 有哪些:

public enum ErrorCode {
    REFRESH_TOKEN_NOT_FOUND,
    REFRESH_TOKEN_EXPIRED,
}

定義 HttpErrorResponse

新增 HttpErrorResponse Dto,定義請求拒絕時 ResponseBody 要有哪些資訊:

public record HttpErrorResponse(
        Instant timestamp,
        int status,
        String error,
        ErrorCode errorCode,
        String message,
        String path
) {
}

自訂 TokenRefreshException

自訂一個 TokenRefreshException 類別,繼承 RuntimeException,供 Refresh Token 相關的 Exception 使用:

@ResponseStatus(HttpStatus.UNAUTHORIZED)
@Getter
public class TokenRefreshException extends RuntimeException {

    private final ErrorCode errorCode;

    public TokenRefreshException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }
    
}

為 TokenRefreshException 建立 Handler

以下定義當系統拋出 TokenRefreshException 時 ResponseBody 內會包含的資訊:

    @ExceptionHandler(TokenRefreshException.class)
    public ResponseEntity<HttpErrorResponse> handleTokenRefreshException(
            TokenRefreshException ex,
            WebRequest request) {

        HttpErrorResponse errorResponse = new HttpErrorResponse(
                Instant.now(),
                HttpStatus.FORBIDDEN.value(),
                HttpStatus.FORBIDDEN.getReasonPhrase(),
                ex.getErrorCode(),
                ex.getMessage(),
                request.getDescription(false).replace("uri=", "")
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN);
    }

這次最主要新增的資訊是 ErrorCode (其他資訊暫時沒有也無妨),希望在安全的前提下(知道錯誤的原因也沒關係的情況下),讓前端可以取得更多錯誤的詳細資訊,例如根據 Http 狀態碼 或 ErrorCode 更彈性設計後續流程。

功能測試

像前幾篇測試一樣,到 API Tester 上新增一個 Request:refreshToken,設定 url 與 method 後,即可開始測試。

案例 1:使用有效的 Refresh Token 換發新憑證

  • 目的:驗證一個合法的 Refresh Token 能夠成功換取一組新的 Access Token、Refresh Token,確認新 Access Token 有效且舊的 Refresh Token 會失效。
  • 前置作業
    1. 呼叫登入 API (POST /users/login) 成功,並取得一組 Token。
    2. 直接使用 refreshToken 進行測試
  • 測試項目與結果
    1. 刷新 Token

      • Request Body

        {
            "refreshToken": "從登入取得的 Refresh Token 字串"
        }
        
      • 預期結果

        • HTTP 狀態碼:200
        • 回應內容:一個包含新的 accessToken新的 refreshToken 的 JSON 物件。
      • 實際結果
        符合預期,回應 200 且取得新的 Token
        https://ithelp.ithome.com.tw/upload/images/20251004/20178099kRk2LS3oDe.png

    2. 確認舊 Refresh Token 不可再次使用

      • Request Body

        {
            "refreshToken": "使用跟上次請求同一組 refreshToken"
        }
        
      • 預期結果

        • HTTP 狀態碼:401
        • 回應內容:ErrorCode 應為 REFRESH_TOKEN_NOT_FOUND
      • 實際結果
        符合預期,回應 401 且 ErrorCode 為 REFRESH_TOKEN_NOT_FOUND。(原先設定回傳 403 後來有更正為 401,下圖是修改前截的圖)
        https://ithelp.ithome.com.tw/upload/images/20251004/201780994tkiN6azry.png

    3. 確認新 Access Token可用

      這邊透過我們先前的 /users/me 端點進行測試,將新的 AccessToken 帶入 Authorization Header。

      • 預期結果

        HTTP 狀態碼:200

      • 實際結果
        符合預期,回應 200並成功存取資源**。**
        https://ithelp.ithome.com.tw/upload/images/20251004/20178099KL4heDmWi9.png

案例 2:使用已過期的 Refresh Token

將 Access Token 跟 Refresh Token 改為短一點的期限,測試過期後收到的回應。收到的結果符合預期,狀態碼為 401 且 ErrorCode 為 REFRESH_TOKEN_EXPIRED。後續到資料庫中也確認了該筆 Token 資料已被刪除。
(原先設定回傳 403 後來有更正為 401,下圖是修改前截的圖)
https://ithelp.ithome.com.tw/upload/images/20251004/20178099IrIYETCVHr.png

應該從成功換發 Token那段內容就看得出不存在資料庫的回應是正確的,就不再放上測試結果與說明~

優化 Access Token ExceptionHandler

因為這次有針對 Refrsh Token 回應的內容設計加入 ErrorCode,所以這邊也順便調整發 Access Token (JWT) 遇到錯誤時的回應。主要希望前端可以在 Access Token 過期時收到特定的訊息,方便判斷要去打 refresh API 的時機。

定義 ErrorCode Enum

public enum ErrorCode {
    // Access Token Errors 除了過期的狀況外不給太多錯誤訊息
    ACCESS_TOKEN_EXPIRED,
    ACCESS_TOKEN_INVALID
}

最主要是希望有過期的特定 ErrorCode,其他錯誤就先丟 ACCESS_TOKEN_INVALID

拋出JWT驗證錯誤

移除原本在這個方法中 catch 的錯誤:

    public void validateJwtToken(String authToken) {
        Jwts.parser()
                .verifyWith(publicKey) // 使用公鑰驗證簽名
                .build()
                .parseSignedClaims(authToken); // 解析帶簽名的 Claims
}

在 Filter 中定義 ErrorResponse

因為以JWT進行驗證的是我們自訂的Filter,所以在 Filter 中加入例外處理的程式碼:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    public JwtAuthenticationFilter( ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException{
        try {
            String jwt = parseJwt(request);

            // 主要改動在此,從if中提出 validateJwtToken 方法
            if (jwt != null) {
                jwtUtils.validateJwtToken(jwt);
								...
            }
            
        //捕捉 validateJwtToken 方法噴出的Exception
        } catch (ExpiredJwtException e) {
            setHttpErrorResponse(response, ErrorCode.ACCESS_TOKEN_EXPIRED, "Access token has expired.");
            logger.error("Cannot set user authentication: {}", e.getMessage(), e);
            return;
        } catch (SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            setHttpErrorResponse(response, ErrorCode.ACCESS_TOKEN_INVALID, "Invalid access token.");
            return;
        }
        filterChain.doFilter(request, response);
    }

  private void setHttpErrorResponse(HttpServletResponse response, ErrorCode errorCode, String message) throws IOException {
      response.setStatus(HttpStatus.UNAUTHORIZED.value());
      response.setContentType(MediaType.APPLICATION_JSON_VALUE);

      HttpErrorResponse errorResponse = new HttpErrorResponse(
              Instant.now(),
              HttpStatus.UNAUTHORIZED.value(),
              HttpStatus.UNAUTHORIZED.getReasonPhrase(),
              errorCode,
              message,
              null // 在 Filter 層級可能不易取得 request path,可為 null
      );

      response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
  }

此處新增定義一個私有的方法 setHttpErrorResponse 用來將自訂的錯誤訊息寫入 ResponseBody。

將調用 validateJwtToken 的段落從原本的判斷式中移除,如果 jwt 不為空,直接執行驗證,並在此捕捉拋出的 Exception。

功能測試

調整完上述內容,以下簡單測試一下 Access Token 過期的狀況,正確回傳401外,ErrorCode也很清楚表示這個驗證失敗是由於 Access Token 過期所導致。下圖為測試修改 JWT Token 的情況,如我們所定義的,ErrorCode 僅說明這是個不合法的 Token,未暴露太多細節。

  • AccessToken 過期

https://ithelp.ithome.com.tw/upload/images/20251004/20178099NldjZRMAtv.png

  • AccessToken 其他錯誤

https://ithelp.ithome.com.tw/upload/images/20251004/20178099hXuWbOeevy.png


到目前為止,我們把以帳號密碼進行註冊、登入,簽發與換發 Token 的功能都實作完成了。緊接著明天預計來試著實作登出功能:)!


上一篇
Day 19:實作 Token 的 Refresh 機制 (1)
系列文
吃出一個SideProject!20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言