Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/main/java/com/semosan/api/common/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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(),
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
);
}

}
15 changes: 8 additions & 7 deletions src/test/java/com/semosan/api/common/jwt/JwtFilterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading