Repository navigation
[feat] 어드민 계정 전환(impersonation) 기능 추가 #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
77 changes: 77 additions & 0 deletions
77
src/main/java/com/daramg/server/admin/presentation/AdminController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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()); | ||
| } | ||
| } | ||
141 changes: 141 additions & 0 deletions
141
src/test/java/com/daramg/server/admin/presentation/AdminControllerTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Comment on lines
+38
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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()); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
중첩 계정 전환(Nested Impersonation) 방지 및 권한 검증 로직이 필요합니다. 현재 로직은 이미 계정 전환이 된 상태에서 다시
impersonate를 호출하면 기존 백업된 어드민 토큰을 유저 토큰으로 덮어씌우게 되어 복귀가 불가능해집니다. 또한, 백업하려는 토큰이 실제ADMIN권한을 가졌는지 확인하는 절차가 보안상 권장됩니다.