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
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));
Comment on lines +46 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

중첩 계정 전환(Nested Impersonation) 방지 및 권한 검증 로직이 필요합니다. 현재 로직은 이미 계정 전환이 된 상태에서 다시 impersonate를 호출하면 기존 백업된 어드민 토큰을 유저 토큰으로 덮어씌우게 되어 복귀가 불가능해집니다. 또한, 백업하려는 토큰이 실제 ADMIN 권한을 가졌는지 확인하는 절차가 보안상 권장됩니다.

Suggested change
String adminToken = CookieUtil.getCookie(request, ACCESS_COOKIE_NAME)
.map(cookie -> cookie.getValue())
.orElseThrow(() -> new BusinessException(AuthErrorStatus.INVALID_TOKEN_EXCEPTION));
String adminToken = CookieUtil.getCookie(request, ACCESS_COOKIE_NAME)
.map(cookie -> cookie.getValue())
.orElseThrow(() -> new BusinessException(AuthErrorStatus.INVALID_TOKEN_EXCEPTION));
if (CookieUtil.getCookie(request, ADMIN_BACKUP_COOKIE_NAME).isPresent() || !"ADMIN".equals(jwtUtil.getRole(adminToken))) {
throw 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

백업 쿠키의 만료 시간을 액세스 토큰의 8배로 설정한 근거가 불분명합니다. 어드민의 전환 유지 시간이 이보다 길어질 경우 복귀 시 쿠키가 만료되어 문제가 발생할 수 있습니다. 해당 값을 상수로 관리하거나 리프레시 토큰 만료 시간 등과 연동하는 것을 고려해 보세요.

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());
}
}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

jwtUtil을 사용하여 스터빙(when(...))을 하고 있으므로, @Autowired 대신 @MockitoBean을 사용하여 모킹(Mocking)해야 합니다. 그렇지 않으면 실제 빈이 주입되어 테스트 실행 시 오류가 발생할 수 있습니다.

Suggested change
@Autowired
private JwtUtil jwtUtil;
@MockitoBean
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());
}
}
Loading