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
Expand Up @@ -97,6 +97,16 @@ public interface AdminUserControllerDocs {
}
""";

String USER_ROLE_ALREADY_ASSIGNED_EXAMPLE = """
{
"success": false,
"status": 409,
"code": "USER_ROLE_ALREADY_ASSIGNED",
"timestamp": "2026-05-17T08:00:00Z",
"message": "이미 해당 권한을 가진 사용자입니다."
}
""";

String USER_ALREADY_DELETED_EXAMPLE = """
{
"success": false,
Expand Down Expand Up @@ -231,7 +241,7 @@ ResponseEntity<ApiResponse<PageResponse<AdminUserResponse>>> getUsers(
@Parameter(description = "사용자 권한", example = "USER") UserRole role,
@Parameter(description = "탈퇴 여부", example = "false") Boolean deleted,
@Parameter(description = "가입 방식", example = "LOCAL") AuthProvider authProvider,
@Parameter(description = "이메일 인증 여부", example = "true") Boolean emailVerified,
@Parameter(description = "이메일 인증 여부", example = "false") Boolean emailVerified,
@Parameter(description = "프로필 완성 여부", example = "true") Boolean profileCompleted
);

Expand Down Expand Up @@ -500,7 +510,7 @@ ResponseEntity<ApiResponse<AdminUserResponse>> updateUser(
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = USER_NOT_FOUND_EXAMPLE))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 탈퇴 처리된 사용자", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = USER_ALREADY_DELETED_EXAMPLE))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 해당 권한을 가진 사용자", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = USER_ROLE_ALREADY_ASSIGNED_EXAMPLE))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = INTERNAL_SERVER_ERROR_EXAMPLE)))
})
ResponseEntity<ApiResponse<AdminUserResponse>> updateUserRole(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ public AdminUserResponse updateUserRole(AuthUser authUser, Long userId, AdminUse
validateNotSelf(authUser, userId);

User user = getUserById(userId);
if (user.getRole() == request.role()) {
throw new CommonException(ErrorCode.USER_ROLE_ALREADY_ASSIGNED);
}

user.changeRole(request.role());
return AdminUserResponse.from(user);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public enum ErrorCode {
ADMIN_ACCESS_FORBIDDEN(403, "ADMIN_ACCESS_FORBIDDEN", "관리자 권한이 필요합니다."),
ADMIN_SELF_ACTION_FORBIDDEN(403, "ADMIN_SELF_ACTION_FORBIDDEN", "관리자 본인 계정에는 수행할 수 없는 작업입니다."),
USER_NOT_FOUND(404, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다."),
USER_ROLE_ALREADY_ASSIGNED(409, "USER_ROLE_ALREADY_ASSIGNED", "이미 해당 권한을 가진 사용자입니다."),
USER_ALREADY_DELETED(409, "USER_ALREADY_DELETED", "이미 탈퇴 처리된 사용자입니다."),
USER_NOT_DELETED(409, "USER_NOT_DELETED", "탈퇴 처리된 사용자가 아닙니다."),
SOCIAL_SIGNUP_TICKET_EXPIRED(401, "SOCIAL_SIGNUP_TICKET_EXPIRED", "소셜 회원가입 티켓이 만료되었습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.example.muneoserver.global.security.config;

import com.example.muneoserver.global.security.handler.CustomAccessDeniedHandler;
import com.example.muneoserver.global.security.handler.CustomAuthenticationEntryPoint;
import com.example.muneoserver.global.security.jwt.JwtAuthenticationFilter;
import com.example.muneoserver.global.security.oauth.CustomOAuth2UserService;
import com.example.muneoserver.global.security.oauth.OAuth2LoginFailureHandler;
Expand Down Expand Up @@ -31,19 +33,25 @@ public class SecurityConfig {
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
private final SecurityProperties securityProperties;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

public SecurityConfig(
JwtAuthenticationFilter jwtAuthenticationFilter,
CustomOAuth2UserService customOAuth2UserService,
OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler,
OAuth2LoginFailureHandler oAuth2LoginFailureHandler,
SecurityProperties securityProperties
SecurityProperties securityProperties,
CustomAccessDeniedHandler customAccessDeniedHandler,
CustomAuthenticationEntryPoint customAuthenticationEntryPoint
) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.customOAuth2UserService = customOAuth2UserService;
this.oAuth2LoginSuccessHandler = oAuth2LoginSuccessHandler;
this.oAuth2LoginFailureHandler = oAuth2LoginFailureHandler;
this.securityProperties = securityProperties;
this.customAccessDeniedHandler = customAccessDeniedHandler;
this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
}

@Bean
Expand All @@ -54,6 +62,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.httpBasic(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.exceptionHandling(exception -> exception
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/users/signup", "/api/v1/users/login", "/api/v1/users/refresh").permitAll()
.requestMatchers("/api/v1/admin/login").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.example.muneoserver.global.security.handler;

import com.example.muneoserver.global.dto.ApiResponse;
import com.example.muneoserver.global.error.exception.ErrorCode;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

private final ObjectMapper objectMapper = JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();

@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException, ServletException {
response.setStatus(ErrorCode.ADMIN_ACCESS_FORBIDDEN.status());
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), ApiResponse.fail(ErrorCode.ADMIN_ACCESS_FORBIDDEN));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.example.muneoserver.global.security.handler;

import com.example.muneoserver.global.dto.ApiResponse;
import com.example.muneoserver.global.error.exception.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

private final ObjectMapper objectMapper = JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException {
response.setStatus(ErrorCode.UNAUTHORIZED.status());
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), ApiResponse.fail(ErrorCode.UNAUTHORIZED));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ void updateUserRoleRejectsSelfAction() {
verify(userRepository, never()).findById(any());
}

@Test
void updateUserRoleRejectsSameRole() {
User user = createUser(2L, "user@example.com", UserRole.USER);
AdminUserRoleUpdateRequest request = new AdminUserRoleUpdateRequest(UserRole.USER);

when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));

assertThatThrownBy(() -> adminUserService.updateUserRole(adminUser, user.getId(), request))
.isInstanceOf(CommonException.class)
.satisfies(exception -> {
CommonException commonException = (CommonException) exception;
assertThat(commonException.getErrorCode()).isEqualTo(ErrorCode.USER_ROLE_ALREADY_ASSIGNED);
});
assertThat(user.getRole()).isEqualTo(UserRole.USER);
}

@Test
void deleteUserWithdrawsUserAndDeletesRefreshToken() {
User user = createUser(2L, "user@example.com", UserRole.USER);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.muneoserver.global.security.handler;

import static org.assertj.core.api.Assertions.assertThat;

import com.example.muneoserver.global.error.exception.ErrorCode;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.access.AccessDeniedException;

class CustomAccessDeniedHandlerTest {

private final ObjectMapper objectMapper = new ObjectMapper();

@Test
void handleWritesAdminAccessForbiddenResponse() throws Exception {
CustomAccessDeniedHandler handler = new CustomAccessDeniedHandler();
MockHttpServletResponse response = new MockHttpServletResponse();

handler.handle(new MockHttpServletRequest(), response, new AccessDeniedException("Forbidden"));

JsonNode body = objectMapper.readTree(response.getContentAsString());
assertThat(response.getStatus()).isEqualTo(ErrorCode.ADMIN_ACCESS_FORBIDDEN.status());
assertThat(body.get("success").asBoolean()).isFalse();
assertThat(body.get("status").asInt()).isEqualTo(ErrorCode.ADMIN_ACCESS_FORBIDDEN.status());
assertThat(body.get("code").asText()).isEqualTo(ErrorCode.ADMIN_ACCESS_FORBIDDEN.code());
assertThat(body.get("message").asText()).isEqualTo(ErrorCode.ADMIN_ACCESS_FORBIDDEN.message());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.example.muneoserver.global.security.handler;

import static org.assertj.core.api.Assertions.assertThat;

import com.example.muneoserver.global.error.exception.ErrorCode;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.InsufficientAuthenticationException;

class CustomAuthenticationEntryPointTest {

private final ObjectMapper objectMapper = new ObjectMapper();

@Test
void commenceWritesUnauthorizedResponse() throws Exception {
CustomAuthenticationEntryPoint entryPoint = new CustomAuthenticationEntryPoint();
MockHttpServletResponse response = new MockHttpServletResponse();

entryPoint.commence(
new MockHttpServletRequest(),
response,
new InsufficientAuthenticationException("Authentication required")
);

JsonNode body = objectMapper.readTree(response.getContentAsString());
assertThat(response.getStatus()).isEqualTo(ErrorCode.UNAUTHORIZED.status());
assertThat(body.get("success").asBoolean()).isFalse();
assertThat(body.get("status").asInt()).isEqualTo(ErrorCode.UNAUTHORIZED.status());
assertThat(body.get("code").asText()).isEqualTo(ErrorCode.UNAUTHORIZED.code());
assertThat(body.get("message").asText()).isEqualTo(ErrorCode.UNAUTHORIZED.message());
}
}
Loading