diff --git a/src/main/java/com/semosan/api/common/jwt/JwtFilter.java b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java index fb1975f7..b450b41b 100644 --- a/src/main/java/com/semosan/api/common/jwt/JwtFilter.java +++ b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java @@ -82,8 +82,10 @@ protected void doFilterInternal( if ("ADMIN".equals(tokenType)) { authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); } else { - User user = userRepository.findById(userId).orElse(null); - if (user != null && user.isSuspended()) { + // 탈퇴(soft-delete)했거나 존재하지 않는 유저는 인증 실패 처리 + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.JWT_USER_WITHDRAWN)); + if (user.isSuspended()) { throw new GeneralException(ErrorStatus.USER_SUSPENDED); } } diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index a276dc20..73463d74 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -39,6 +39,7 @@ public enum ErrorStatus implements BaseStatus { REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT_401_11", "리프레시 토큰이 존재하지 않습니다."), REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "JWT_401_12", "리프레시 토큰 정보가 사용자 정보와 일치하지 않습니다."), JWT_EXTRACT_ROLE_FAILED(HttpStatus.UNAUTHORIZED, "JWT_401_13", "토큰에서 사용자 Role을 추출할 수 없습니다."), + JWT_USER_WITHDRAWN(HttpStatus.UNAUTHORIZED, "JWT_401_14", "탈퇴했거나 존재하지 않는 사용자입니다."), /** * Kakao OAuth diff --git a/src/main/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcher.java b/src/main/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcher.java index d5d46431..f64ccb43 100644 --- a/src/main/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcher.java +++ b/src/main/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcher.java @@ -1,13 +1,16 @@ package com.semosan.api.domain.auth.dispatcher; -import com.semosan.api.common.jwt.JwtService; import com.semosan.api.domain.auth.event.UserWithdrawCleanupRequestedEvent; import com.semosan.api.domain.notification.service.FcmTokenService; +import com.semosan.api.domain.oauth.client.OAuthKakaoClient; +import com.semosan.api.domain.user.enums.user.OAuthProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +// FCM 토큰 삭제, 카카오 연동 해제 등 외부 서비스 정리 작업을 담당한다. +// JWT 블랙리스트 처리는 UserWithdrawCleanupEventListener에서 동기로 즉시 처리하므로 여기서 다루지 않는다. @Slf4j @Component @RequiredArgsConstructor @@ -16,8 +19,8 @@ public class UserWithdrawCleanupDispatcher { private static final int MAX_RETRY_COUNT = 3; private static final long INITIAL_BACKOFF_MS = 200L; - private final JwtService jwtService; private final FcmTokenService fcmTokenService; + private final OAuthKakaoClient oAuthKakaoClient; @Async("authCleanupTaskExecutor") public void dispatch(UserWithdrawCleanupRequestedEvent event) { @@ -30,13 +33,14 @@ void cleanupWithRetry(UserWithdrawCleanupRequestedEvent event) { for (int attempt = 1; attempt <= MAX_RETRY_COUNT; attempt++) { try { fcmTokenService.deleteAllByUserId(event.userId()); - jwtService.blacklistAccessToken(event.accessToken()); - jwtService.deleteRefreshToken(event.userId()); + if (event.provider() == OAuthProvider.KAKAO) { + oAuthKakaoClient.unlinkKakaoUser(event.oauthId()); + } return; } catch (RuntimeException e) { lastFailure = e; log.warn( - "회원 탈퇴 후 JWT 정리 실패 (attempt={}/{}, userId={}): {}", + "회원 탈퇴 후 FCM/카카오 정리 실패 (attempt={}/{}, userId={}): {}", attempt, MAX_RETRY_COUNT, event.userId(), @@ -45,14 +49,14 @@ void cleanupWithRetry(UserWithdrawCleanupRequestedEvent event) { if (attempt < MAX_RETRY_COUNT) { if (!sleepBackoff(attempt)) { - log.warn("회원 탈퇴 후 JWT/FCM 정리 재시도 중단 (userId={})", event.userId()); + log.warn("회원 탈퇴 후 FCM/카카오 정리 재시도 중단 (userId={})", event.userId()); return; } } } } - log.error("회원 탈퇴 후 JWT/FCM 정리 최종 실패 (userId={})", event.userId(), lastFailure); + log.error("회원 탈퇴 후 FCM/카카오 정리 최종 실패 (userId={})", event.userId(), lastFailure); } boolean sleepBackoff(int attempt) { diff --git a/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListener.java b/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListener.java index 349cb107..dcd1b573 100644 --- a/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListener.java +++ b/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListener.java @@ -1,19 +1,35 @@ package com.semosan.api.domain.auth.event; +import com.semosan.api.common.jwt.JwtService; import com.semosan.api.domain.auth.dispatcher.UserWithdrawCleanupDispatcher; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +@Slf4j @Component @RequiredArgsConstructor public class UserWithdrawCleanupEventListener { + private final JwtService jwtService; private final UserWithdrawCleanupDispatcher dispatcher; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onUserWithdrawCleanupRequested(UserWithdrawCleanupRequestedEvent event) { + invalidateTokens(event); dispatcher.dispatch(event); } + + // logout()과 동일하게 액세스 토큰 블랙리스트 + refresh token 삭제는 동기로 즉시 처리한다. + // JwtFilter가 탈퇴(deleted) 유저를 별도로 걸러내므로, 여기서 실패해도 보안 구멍으로 이어지지 않는다. + private void invalidateTokens(UserWithdrawCleanupRequestedEvent event) { + try { + jwtService.blacklistAccessToken(event.accessToken()); + jwtService.deleteRefreshToken(event.userId()); + } catch (RuntimeException e) { + log.warn("회원 탈퇴 후 토큰 무효화 실패 (userId={}): {}", event.userId(), e.getMessage()); + } + } } diff --git a/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupRequestedEvent.java b/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupRequestedEvent.java index 67acdd32..62cc1380 100644 --- a/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupRequestedEvent.java +++ b/src/main/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupRequestedEvent.java @@ -1,4 +1,13 @@ package com.semosan.api.domain.auth.event; -public record UserWithdrawCleanupRequestedEvent(Long userId, String accessToken) { +import com.semosan.api.domain.user.enums.user.OAuthProvider; + +// oauthId/provider는 User.withdraw()로 익명화되기 전의 원본 값을 담는다. +// (카카오 연동 해제 등 탈퇴 후 정리 작업에 필요) +public record UserWithdrawCleanupRequestedEvent( + Long userId, + String accessToken, + String oauthId, + OAuthProvider provider +) { } diff --git a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java index 2bb23026..afaa0ee1 100644 --- a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java +++ b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java @@ -9,6 +9,7 @@ import com.semosan.api.domain.auth.dto.response.ReissueResponse; import com.semosan.api.domain.auth.event.UserWithdrawCleanupRequestedEvent; import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.OAuthProvider; import com.semosan.api.domain.user.service.UserReader; import com.semosan.api.domain.user.service.UserService; import io.jsonwebtoken.Claims; @@ -66,8 +67,14 @@ public void logout(Long userId, String accessToken) { @Transactional public void withdraw(Long userId, String accessToken) { User user = userReader.findActiveUserById(userId); + // withdrawUser()가 oauthId를 익명화하기 전에 원본 값을 먼저 확보해둔다. + String oauthId = user.getOauthId(); + OAuthProvider provider = user.getOauthProvider(); + userService.withdrawUser(user); - eventPublisher.publishEvent(new UserWithdrawCleanupRequestedEvent(userId, accessToken)); + eventPublisher.publishEvent( + new UserWithdrawCleanupRequestedEvent(userId, accessToken, oauthId, provider) + ); } } diff --git a/src/test/java/com/semosan/api/common/jwt/JwtFilterTest.java b/src/test/java/com/semosan/api/common/jwt/JwtFilterTest.java index 298e4a29..483e6895 100644 --- a/src/test/java/com/semosan/api/common/jwt/JwtFilterTest.java +++ b/src/test/java/com/semosan/api/common/jwt/JwtFilterTest.java @@ -53,7 +53,7 @@ void doFilterSetsAuthenticationForValidUserToken() throws Exception { when(jwtService.isAccessTokenBlacklisted("access")).thenReturn(false); when(jwtService.getUserIdFromClaims(claims)).thenReturn(1L); when(claims.get("tokenType", String.class)).thenReturn(null); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); MockHttpServletRequest request = request("/api/mountains", "Bearer access"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -80,7 +80,7 @@ void doFilterSetsAdminAuthorityForAdminToken() throws Exception { assertThat(SecurityContextHolder.getContext().getAuthentication().getAuthorities()) .extracting("authority") .containsExactly("ROLE_ADMIN"); - verify(userRepository, never()).findById(9L); + verify(userRepository, never()).findByIdAndDeletedFalse(9L); } @Test @@ -130,7 +130,7 @@ void doFilterWritesErrorResponseWhenUserIsSuspended() throws Exception { when(jwtService.isAccessTokenBlacklisted("access")).thenReturn(false); when(jwtService.getUserIdFromClaims(claims)).thenReturn(1L); when(claims.get("tokenType", String.class)).thenReturn(null); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); MockHttpServletRequest request = request("/api/mountains", "Bearer access"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -155,20 +155,21 @@ void doFilterWritesErrorResponseWhenJwtServiceThrows() throws Exception { } @Test - void doFilterSetsAuthenticationEvenWhenUserIsNotFound() throws Exception { + void doFilterWritesErrorResponseWhenUserIsWithdrawnOrNotFound() throws Exception { Claims claims = mock(Claims.class); when(jwtService.validateAccessTokenAndGetClaims("access")).thenReturn(claims); when(jwtService.isAccessTokenBlacklisted("access")).thenReturn(false); when(jwtService.getUserIdFromClaims(claims)).thenReturn(1L); when(claims.get("tokenType", String.class)).thenReturn(null); - when(userRepository.findById(1L)).thenReturn(Optional.empty()); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.empty()); MockHttpServletRequest request = request("/api/mountains", "Bearer access"); MockHttpServletResponse response = new MockHttpServletResponse(); filter().doFilter(request, response, new MockFilterChain()); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); - assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(1L); + assertThat(response.getStatus()).isEqualTo(ErrorStatus.JWT_USER_WITHDRAWN.getHttpStatus().value()); + assertThat(response.getContentAsString()).contains(ErrorStatus.JWT_USER_WITHDRAWN.getCode()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } private JwtFilter filter() { diff --git a/src/test/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcherTest.java b/src/test/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcherTest.java index 43f0f590..56fe92ec 100644 --- a/src/test/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcherTest.java +++ b/src/test/java/com/semosan/api/domain/auth/dispatcher/UserWithdrawCleanupDispatcherTest.java @@ -1,8 +1,9 @@ package com.semosan.api.domain.auth.dispatcher; -import com.semosan.api.common.jwt.JwtService; import com.semosan.api.domain.auth.event.UserWithdrawCleanupRequestedEvent; import com.semosan.api.domain.notification.service.FcmTokenService; +import com.semosan.api.domain.oauth.client.OAuthKakaoClient; +import com.semosan.api.domain.user.enums.user.OAuthProvider; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -15,67 +16,85 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class UserWithdrawCleanupDispatcherTest { @Mock - private JwtService jwtService; + private FcmTokenService fcmTokenService; @Mock - private FcmTokenService fcmTokenService; + private OAuthKakaoClient oAuthKakaoClient; @InjectMocks private UserWithdrawCleanupDispatcher dispatcher; @Test - void cleanupWithRetry_retriesOnceWhenJwtFails() { - UserWithdrawCleanupRequestedEvent event = new UserWithdrawCleanupRequestedEvent(1L, "access-token"); + void cleanupWithRetry_unlinksKakaoWhenProviderIsKakao() { + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "kakao-1", OAuthProvider.KAKAO); + + dispatcher.cleanupWithRetry(event); + + verify(oAuthKakaoClient).unlinkKakaoUser("kakao-1"); + } + + @Test + void cleanupWithRetry_skipsKakaoUnlinkWhenProviderIsNotKakao() { + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "apple-1", OAuthProvider.APPLE); + + dispatcher.cleanupWithRetry(event); + + verify(oAuthKakaoClient, never()).unlinkKakaoUser(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cleanupWithRetry_retriesOnceWhenKakaoUnlinkFails() { + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "kakao-1", OAuthProvider.KAKAO); doNothing().when(fcmTokenService).deleteAllByUserId(1L); - doThrow(new RuntimeException("redis down")) + doThrow(new RuntimeException("kakao down")) .doNothing() - .when(jwtService).blacklistAccessToken("access-token"); - doNothing().when(jwtService).deleteRefreshToken(1L); + .when(oAuthKakaoClient).unlinkKakaoUser("kakao-1"); assertDoesNotThrow(() -> dispatcher.cleanupWithRetry(event)); - verify(jwtService, times(2)).blacklistAccessToken("access-token"); - verify(jwtService, times(1)).deleteRefreshToken(1L); + verify(oAuthKakaoClient, times(2)).unlinkKakaoUser("kakao-1"); verify(fcmTokenService, times(2)).deleteAllByUserId(1L); } @Test void dispatchDelegatesToCleanup() { - UserWithdrawCleanupRequestedEvent event = new UserWithdrawCleanupRequestedEvent(1L, "access-token"); + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "kakao-1", OAuthProvider.KAKAO); doNothing().when(fcmTokenService).deleteAllByUserId(1L); - doNothing().when(jwtService).blacklistAccessToken("access-token"); - doNothing().when(jwtService).deleteRefreshToken(1L); dispatcher.dispatch(event); verify(fcmTokenService).deleteAllByUserId(1L); - verify(jwtService).blacklistAccessToken("access-token"); - verify(jwtService).deleteRefreshToken(1L); + verify(oAuthKakaoClient).unlinkKakaoUser("kakao-1"); } @Test void cleanupWithRetryStopsAfterThreeFailures() { - UserWithdrawCleanupRequestedEvent event = new UserWithdrawCleanupRequestedEvent(1L, "access-token"); + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "kakao-1", OAuthProvider.KAKAO); doThrow(new RuntimeException("fcm down")).when(fcmTokenService).deleteAllByUserId(1L); assertDoesNotThrow(() -> dispatcher.cleanupWithRetry(event)); verify(fcmTokenService, times(3)).deleteAllByUserId(1L); - verify(jwtService, never()).blacklistAccessToken("access-token"); - verify(jwtService, never()).deleteRefreshToken(1L); + verify(oAuthKakaoClient, never()).unlinkKakaoUser(org.mockito.ArgumentMatchers.anyString()); } @Test void cleanupWithRetryStopsWhenBackoffIsInterrupted() { - UserWithdrawCleanupRequestedEvent event = new UserWithdrawCleanupRequestedEvent(1L, "access-token"); + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "kakao-1", OAuthProvider.KAKAO); doThrow(new RuntimeException("fcm down")).when(fcmTokenService).deleteAllByUserId(1L); Thread.currentThread().interrupt(); @@ -86,8 +105,7 @@ void cleanupWithRetryStopsWhenBackoffIsInterrupted() { Thread.interrupted(); } verify(fcmTokenService, times(1)).deleteAllByUserId(1L); - verify(jwtService, never()).blacklistAccessToken("access-token"); - verify(jwtService, never()).deleteRefreshToken(1L); + verify(oAuthKakaoClient, never()).unlinkKakaoUser(org.mockito.ArgumentMatchers.anyString()); } @Test diff --git a/src/test/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListenerTest.java b/src/test/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListenerTest.java index eca57450..088bcee1 100644 --- a/src/test/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListenerTest.java +++ b/src/test/java/com/semosan/api/domain/auth/event/UserWithdrawCleanupEventListenerTest.java @@ -1,21 +1,48 @@ package com.semosan.api.domain.auth.event; +import com.semosan.api.common.jwt.JwtService; import com.semosan.api.domain.auth.dispatcher.UserWithdrawCleanupDispatcher; +import com.semosan.api.domain.user.enums.user.OAuthProvider; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; -import static org.mockito.Mockito.mock; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; +@ExtendWith(MockitoExtension.class) class UserWithdrawCleanupEventListenerTest { + @Mock + private JwtService jwtService; + + @Mock + private UserWithdrawCleanupDispatcher dispatcher; + @Test - void onUserWithdrawCleanupRequestedDelegatesToDispatcher() { - UserWithdrawCleanupDispatcher dispatcher = mock(UserWithdrawCleanupDispatcher.class); - UserWithdrawCleanupEventListener listener = new UserWithdrawCleanupEventListener(dispatcher); - UserWithdrawCleanupRequestedEvent event = new UserWithdrawCleanupRequestedEvent(1L, "access-token"); + void onUserWithdrawCleanupRequestedInvalidatesTokensAndDelegatesToDispatcher() { + UserWithdrawCleanupEventListener listener = new UserWithdrawCleanupEventListener(jwtService, dispatcher); + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "kakao-1", OAuthProvider.KAKAO); listener.onUserWithdrawCleanupRequested(event); + verify(jwtService).blacklistAccessToken("access-token"); + verify(jwtService).deleteRefreshToken(1L); + verify(dispatcher).dispatch(event); + } + + @Test + void onUserWithdrawCleanupRequestedStillDelegatesWhenTokenInvalidationFails() { + UserWithdrawCleanupEventListener listener = new UserWithdrawCleanupEventListener(jwtService, dispatcher); + UserWithdrawCleanupRequestedEvent event = + new UserWithdrawCleanupRequestedEvent(1L, "access-token", "kakao-1", OAuthProvider.KAKAO); + doThrow(new RuntimeException("redis down")).when(jwtService).blacklistAccessToken("access-token"); + + assertDoesNotThrow(() -> listener.onUserWithdrawCleanupRequested(event)); + verify(dispatcher).dispatch(event); } } diff --git a/src/test/java/com/semosan/api/domain/auth/service/AuthServiceTest.java b/src/test/java/com/semosan/api/domain/auth/service/AuthServiceTest.java index 6a4d1b26..d21899f4 100644 --- a/src/test/java/com/semosan/api/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/semosan/api/domain/auth/service/AuthServiceTest.java @@ -119,6 +119,8 @@ void withdrawWithdrawsUserAndPublishesCleanupEvent() { verify(eventPublisher).publishEvent(captor.capture()); assertThat(captor.getValue().userId()).isEqualTo(1L); assertThat(captor.getValue().accessToken()).isEqualTo("access"); + assertThat(captor.getValue().oauthId()).isEqualTo("test-1"); + assertThat(captor.getValue().provider()).isEqualTo(com.semosan.api.domain.user.enums.user.OAuthProvider.TEST); } private User user(Long id, OnboardingStatus onboardingStatus) {