iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0

目前以帳號密碼登入的功能都做得差不多了,所以很自然的想來試試看登出功能~~

不過因為我們選用 JWT ,特性就是伺服器並不會記憶該用戶 Session,也沒有把 Token 存入資料庫中,讓用戶即時有感的登出可能就要留給屆時前端實作。

不過,在現行架構下我們還有一件事情可以做,就是在用戶登出時資料庫中刪除 RefreshToken!
如此一來,即便 Token 不幸外洩,只要原本的 Access Token 過期,就必須強制重新登入才能取得權限了。

Controller:端點與參數

定義登出時需傳入的參數,目前僅有 refreshToken(承襲前幾天的做法,從簡的將 token 都先放在 body 中進行傳送):

public record LogoutRequest (
        String refreshToken
){
}

新增一個端點在AuthController,端點為 /users/logout 。因為目前我們簡單的將 Refresh Token ,並且 lougout 未來或許會包含更多動作,因此比起 Delete ,這邊我會先選用 Post 的 Http method:

@PostMapping("/logout")
public ResponseEntity<?> logoutUser(@Valid @RequestBody LogoutRequest logoutRequest,@AuthenticationPrincipal UserEntity currentUser) {

    refreshTokenService.deleteByToken(logoutRequest.refreshToken(), currentUser);

    return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

為了在後續檢察使用者身分是否可執行此操作,調用方法中傳入當前驗證完成的使用者資訊。

RefreshTokenService:deleteByToken

加入 deleteByToken 的方法。對於 logout 這件事,目前只有刪除 RefreshToken,未來或許有其他行為不屬於這個服務,以 deleteByToken 命名最為合適:

@Override
@Transactional
public void deleteByToken(String refreshToken, UserEntity currentUser) {

    RefreshTokenEntity refreshTokenObj = refreshTokenRepository.findByToken(refreshToken)
                    .orElseThrow(() -> new TokenRefreshException(ErrorCode.REFRESH_TOKEN_NOT_FOUND, "Refresh token not found"));

    boolean isAdmin = currentUser.getAuthorities().stream()
            .anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_ADMIN"));

    Long tokenOwnerId = refreshTokenObj.getUser().getId();
    Long currentUserId = currentUser.getId();
    
    if (!tokenOwnerId.equals(currentUserId) && !isAdmin){
        logger.warn(" User '{}' attempting to log out session of another user '{}'",
                currentUserId,tokenOwnerId) ;

        throw new AccessDeniedException("You do not have permission to perform this action");
    }

    // 如為本人可刪除
    refreshTokenRepository.delete(refreshTokenObj);
}

除了檢查 Token存在與否,這邊也檢查使用者身分是否可執行本次操作,預計可執行登出的應該只有本人與管理員。

HttpSecurity

原本在建立登入與註冊請求將 /users 下的端點都設定為permitAll,既然現在有新增的功能,就將權限控管更清楚地寫下來:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .csrf(csrf -> csrf.disable())
            .exceptionHandling(exception -> exception.authenticationEntryPoint(authEntryPoint))
            .authorizeHttpRequests(auth -> auth
				    // 登入跟註冊還沒拿到Token,開放所有人打
                    .requestMatchers("/users/login", "/users/register").permitAll()
                    // 其他的請求要經過驗證
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

功能測試

因為前面幾篇已經做過很多次,這邊就簡單列出步驟跟測試結果~

步驟

  1. 到 API Tester 中新增一個 logout 端點,設定好 url 與 method。
  2. 先登入取得 Access Token 與 Refresh Token。
  3. Authorization header 帶入 Bear ${剛剛取得的 Access Token}
  4. Requestbody 帶入 refreshToken 參數

測試結果

目前沒有需要回傳的內容,成功回傳 204 ,資料庫也正確刪除這筆資源。

https://ithelp.ithome.com.tw/upload/images/20251005/20178099GfSDCtoXL6.png


今天,完成了身份驗證流程的登出功能。透過在後端撤銷 Refresh Token,算是實作了一個兼顧無狀態架構與安全性的登出機制(?)

因為跟原本預期的進度小有落差,打算花點時間調整未來九天的內容,或許這三十天就專注在學習驗證與授權服務~也很想試著實作看看第三方登入的功能,因此今天內容相對簡略,敬請見諒:)!


上一篇
Day 20:實作 JWT Token Refresh 機制 (2)
下一篇
Day 22:第三方登入- OAuth2.0 與 OIDC
系列文
吃出一個SideProject!24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言