接續昨天我們在登入回應中加入 Refresh Token,確認了前端可以在登入成功後同時接收到兩組(TokenAccess Token 與 Refresh Token)
今天要來接續實作 Token 換 Token 的 Refresh 機制了~~
因為 Refresh 過程中我們會去資料庫驗證有沒有這組 Token,以及這組 Token 有沒有過期(如果過期資料表中需要刪除這筆資料),因此到 RefreshTokenRepository
中加入以下兩個方法:
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
Optional<RefreshTokenEntity> findByToken(String token);
void deleteByToken(String token);
}
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 的建立,所以會跟前面幾天的呼叫方式有點不太一樣。很簡單的修改就不附上程式碼了)
分別建立 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 層其他方法的內容,先前把太多業務邏輯寫在這層,一樣僅是重構不影響功能,就不附上程式碼~)
為了讓前端更清楚換發時發生的錯誤,決定自訂 Exception 加上 ExceptionHandler 來處理換發時的錯誤回應。
新增一個存放 enum 的 package ,定義一個 ErrorCode enum ,宣告我們可能會在回應中包含的 ErrorCode 有哪些:
public enum ErrorCode {
REFRESH_TOKEN_NOT_FOUND,
REFRESH_TOKEN_EXPIRED,
}
新增 HttpErrorResponse Dto,定義請求拒絕時 ResponseBody 要有哪些資訊:
public record HttpErrorResponse(
Instant timestamp,
int status,
String error,
ErrorCode errorCode,
String message,
String path
) {
}
自訂一個 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 時 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 後,即可開始測試。
POST /users/login
) 成功,並取得一組 Token。refreshToken
進行測試刷新 Token
Request Body
{
"refreshToken": "從登入取得的 Refresh Token 字串"
}
預期結果
accessToken
/新的 refreshToken
的 JSON 物件。實際結果
符合預期,回應 200 且取得新的 Token
確認舊 Refresh Token 不可再次使用
Request Body
{
"refreshToken": "使用跟上次請求同一組 refreshToken"
}
預期結果
REFRESH_TOKEN_NOT_FOUND
實際結果:
符合預期,回應 401 且 ErrorCode 為 REFRESH_TOKEN_NOT_FOUND
。(原先設定回傳 403 後來有更正為 401,下圖是修改前截的圖)
確認新 Access Token可用
這邊透過我們先前的 /users/me
端點進行測試,將新的 AccessToken 帶入 Authorization Header。
預期結果
HTTP 狀態碼:200
實際結果:
符合預期,回應 200並成功存取資源**。**
將 Access Token 跟 Refresh Token 改為短一點的期限,測試過期後收到的回應。收到的結果符合預期,狀態碼為 401 且 ErrorCode 為 REFRESH_TOKEN_EXPIRED
。後續到資料庫中也確認了該筆 Token 資料已被刪除。
(原先設定回傳 403 後來有更正為 401,下圖是修改前截的圖)
應該從成功換發 Token那段內容就看得出不存在資料庫的回應是正確的,就不再放上測試結果與說明~
因為這次有針對 Refrsh Token 回應的內容設計加入 ErrorCode,所以這邊也順便調整發 Access Token (JWT) 遇到錯誤時的回應。主要希望前端可以在 Access Token 過期時收到特定的訊息,方便判斷要去打 refresh API 的時機。
public enum ErrorCode {
// Access Token Errors 除了過期的狀況外不給太多錯誤訊息
ACCESS_TOKEN_EXPIRED,
ACCESS_TOKEN_INVALID
}
最主要是希望有過期的特定 ErrorCode,其他錯誤就先丟 ACCESS_TOKEN_INVALID
移除原本在這個方法中 catch 的錯誤:
public void validateJwtToken(String authToken) {
Jwts.parser()
.verifyWith(publicKey) // 使用公鑰驗證簽名
.build()
.parseSignedClaims(authToken); // 解析帶簽名的 Claims
}
因為以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,未暴露太多細節。
到目前為止,我們把以帳號密碼進行註冊、登入,簽發與換發 Token 的功能都實作完成了。緊接著明天預計來試著實作登出功能:)!