From 47742f28cc2d93657cfbee8d6da9bffb06ff6564 Mon Sep 17 00:00:00 2001 From: k3vin7 Date: Wed, 8 Apr 2026 12:49:42 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=A0=84=ED=99=98(impersonation)=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/presentation/AdminController.java | 77 ++++++++++ .../presentation/AdminControllerTest.java | 141 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 src/main/java/com/daramg/server/admin/presentation/AdminController.java create mode 100644 src/test/java/com/daramg/server/admin/presentation/AdminControllerTest.java diff --git a/src/main/java/com/daramg/server/admin/presentation/AdminController.java b/src/main/java/com/daramg/server/admin/presentation/AdminController.java new file mode 100644 index 0000000..32192a2 --- /dev/null +++ b/src/main/java/com/daramg/server/admin/presentation/AdminController.java @@ -0,0 +1,77 @@ +package com.daramg.server.admin.presentation; + +import com.daramg.server.auth.exception.AuthErrorStatus; +import com.daramg.server.auth.util.CookieUtil; +import com.daramg.server.auth.util.JwtUtil; +import com.daramg.server.common.exception.BusinessException; +import com.daramg.server.user.domain.User; +import com.daramg.server.user.exception.UserErrorStatus; +import com.daramg.server.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminController { + + @Value("${jwt.access-time}") + private long accessTokenLifetimeInMillis; + + @Value("${cookie.access-name}") + private String ACCESS_COOKIE_NAME; + + @Value("${cookie.domain:}") + private String cookieDomain; + + private static final String ADMIN_BACKUP_COOKIE_NAME = "admin_backup"; + + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + + @PostMapping("/impersonate/{userId}") + @ResponseStatus(HttpStatus.OK) + public void impersonate( + @PathVariable Long userId, + HttpServletRequest request, + HttpServletResponse response + ) { + // 현재 어드민 AT를 백업 쿠키에 저장 + String adminToken = CookieUtil.getCookie(request, ACCESS_COOKIE_NAME) + .map(cookie -> cookie.getValue()) + .orElseThrow(() -> new BusinessException(AuthErrorStatus.INVALID_TOKEN_EXCEPTION)); + + User targetUser = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(UserErrorStatus.USER_NOT_FOUND)); + + ResponseCookie backupCookie = CookieUtil.createCookie( + ADMIN_BACKUP_COOKIE_NAME, adminToken, accessTokenLifetimeInMillis * 8, cookieDomain); + response.addHeader(HttpHeaders.SET_COOKIE, backupCookie.toString()); + + String impersonatedToken = jwtUtil.createAccessToken(targetUser); + ResponseCookie accessCookie = CookieUtil.createCookie( + ACCESS_COOKIE_NAME, impersonatedToken, accessTokenLifetimeInMillis, cookieDomain); + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + } + + @PostMapping("/revert") + @ResponseStatus(HttpStatus.OK) + public void revert(HttpServletRequest request, HttpServletResponse response) { + String adminToken = CookieUtil.getCookie(request, ADMIN_BACKUP_COOKIE_NAME) + .map(cookie -> cookie.getValue()) + .orElseThrow(() -> new BusinessException(AuthErrorStatus.INVALID_TOKEN_EXCEPTION)); + + ResponseCookie accessCookie = CookieUtil.createCookie( + ACCESS_COOKIE_NAME, adminToken, accessTokenLifetimeInMillis, cookieDomain); + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + + ResponseCookie deleteBackup = CookieUtil.deleteCookie(ADMIN_BACKUP_COOKIE_NAME, cookieDomain); + response.addHeader(HttpHeaders.SET_COOKIE, deleteBackup.toString()); + } +} diff --git a/src/test/java/com/daramg/server/admin/presentation/AdminControllerTest.java b/src/test/java/com/daramg/server/admin/presentation/AdminControllerTest.java new file mode 100644 index 0000000..c357bd4 --- /dev/null +++ b/src/test/java/com/daramg/server/admin/presentation/AdminControllerTest.java @@ -0,0 +1,141 @@ +package com.daramg.server.admin.presentation; + +import com.daramg.server.auth.util.JwtUtil; +import com.daramg.server.testsupport.support.ControllerTestSupport; +import com.daramg.server.user.domain.User; +import com.daramg.server.user.repository.UserRepository; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDate; +import java.util.Optional; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AdminController.class) +public class AdminControllerTest extends ControllerTestSupport { + + @MockitoBean + private UserRepository userRepository; + + @Autowired + private JwtUtil jwtUtil; + + private static final String ADMIN_BACKUP_COOKIE_NAME = "admin_backup"; + + private User createTestUser(Long id) { + User user = User.builder() + .email("test@squirrel.cl") + .password("encodedPassword") + .name("테스트유저") + .birthDate(LocalDate.of(1999, 1, 1)) + .nickname("테스트닉네임") + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + void 어드민이_특정_유저_계정으로_전환() throws Exception { + // given + User targetUser = createTestUser(10L); + when(userRepository.findById(10L)).thenReturn(Optional.of(targetUser)); + when(jwtUtil.createAccessToken(any(User.class))).thenReturn("impersonated-token"); + + // when + ResultActions result = mockMvc.perform(post("/admin/impersonate/{userId}", 10L) + .cookie(new Cookie(COOKIE_NAME, "admin-access-token"))); + + // then + result.andExpect(status().isOk()) + .andExpect(cookie().value(COOKIE_NAME, "impersonated-token")) + .andExpect(cookie().value(ADMIN_BACKUP_COOKIE_NAME, "admin-access-token")) + .andDo(document("어드민_계정_전환", + resource(ResourceSnippetParameters.builder() + .tag("Admin API") + .summary("계정 전환") + .description("어드민이 특정 유저 계정으로 전환합니다. 기존 어드민 토큰은 admin_backup 쿠키에 저장됩니다.") + .build() + ), + pathParameters( + parameterWithName("userId").description("전환할 유저 ID") + ), + requestCookies( + cookieWithName(COOKIE_NAME).description("어드민 액세스 토큰") + ), + responseCookies( + cookieWithName(COOKIE_NAME).description("전환된 유저의 액세스 토큰"), + cookieWithName(ADMIN_BACKUP_COOKIE_NAME).description("백업된 어드민 액세스 토큰") + ) + )); + } + + @Test + void 존재하지_않는_유저로_전환_시_실패() throws Exception { + // given + when(userRepository.findById(any())).thenReturn(Optional.empty()); + + // when + ResultActions result = mockMvc.perform(post("/admin/impersonate/{userId}", 999L) + .cookie(new Cookie(COOKIE_NAME, "admin-access-token"))); + + // then + result.andExpect(status().isNotFound()); + } + + @Test + void 어드민_계정으로_복귀() throws Exception { + // when + ResultActions result = mockMvc.perform(post("/admin/revert") + .cookie(new Cookie(COOKIE_NAME, "impersonated-token")) + .cookie(new Cookie(ADMIN_BACKUP_COOKIE_NAME, "admin-access-token"))); + + // then + result.andExpect(status().isOk()) + .andExpect(cookie().value(COOKIE_NAME, "admin-access-token")) + .andExpect(cookie().value(ADMIN_BACKUP_COOKIE_NAME, "")) + .andDo(document("어드민_계정_복귀", + resource(ResourceSnippetParameters.builder() + .tag("Admin API") + .summary("어드민 계정 복귀") + .description("백업된 어드민 토큰으로 복귀합니다. admin_backup 쿠키가 삭제되고 어드민 액세스 토큰이 복원됩니다.") + .build() + ), + requestCookies( + cookieWithName(COOKIE_NAME).description("현재 전환된 유저의 액세스 토큰"), + cookieWithName(ADMIN_BACKUP_COOKIE_NAME).description("백업된 어드민 액세스 토큰") + ), + responseCookies( + cookieWithName(COOKIE_NAME).description("복원된 어드민 액세스 토큰"), + cookieWithName(ADMIN_BACKUP_COOKIE_NAME).description("삭제된 백업 쿠키") + ) + )); + } + + @Test + void 백업_쿠키_없이_복귀_시_실패() throws Exception { + // when + ResultActions result = mockMvc.perform(post("/admin/revert") + .cookie(new Cookie(COOKIE_NAME, "impersonated-token"))); + + // then + result.andExpect(status().isUnauthorized()); + } +}