diff --git a/src/main/java/com/example/muneoserver/domain/user/controller/docs/AdminUserControllerDocs.java b/src/main/java/com/example/muneoserver/domain/user/controller/docs/AdminUserControllerDocs.java index 54c7d36..d34549b 100644 --- a/src/main/java/com/example/muneoserver/domain/user/controller/docs/AdminUserControllerDocs.java +++ b/src/main/java/com/example/muneoserver/domain/user/controller/docs/AdminUserControllerDocs.java @@ -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, @@ -231,7 +241,7 @@ ResponseEntity>> 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 ); @@ -500,7 +510,7 @@ ResponseEntity> 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> updateUserRole( diff --git a/src/main/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImpl.java b/src/main/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImpl.java index ee07f26..8fd4af0 100644 --- a/src/main/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImpl.java +++ b/src/main/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImpl.java @@ -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); } diff --git a/src/main/java/com/example/muneoserver/global/error/exception/ErrorCode.java b/src/main/java/com/example/muneoserver/global/error/exception/ErrorCode.java index d8316a6..1b51295 100644 --- a/src/main/java/com/example/muneoserver/global/error/exception/ErrorCode.java +++ b/src/main/java/com/example/muneoserver/global/error/exception/ErrorCode.java @@ -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", "소셜 회원가입 티켓이 만료되었습니다."), diff --git a/src/main/java/com/example/muneoserver/global/security/config/SecurityConfig.java b/src/main/java/com/example/muneoserver/global/security/config/SecurityConfig.java index 18a1e61..09b5738 100644 --- a/src/main/java/com/example/muneoserver/global/security/config/SecurityConfig.java +++ b/src/main/java/com/example/muneoserver/global/security/config/SecurityConfig.java @@ -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; @@ -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 @@ -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() diff --git a/src/main/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandler.java b/src/main/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..d8de91e --- /dev/null +++ b/src/main/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandler.java @@ -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)); + } +} diff --git a/src/main/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..a178edd --- /dev/null +++ b/src/main/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPoint.java @@ -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)); + } +} diff --git a/src/test/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImplTest.java b/src/test/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImplTest.java index 1271a5b..e8dddfd 100644 --- a/src/test/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImplTest.java +++ b/src/test/java/com/example/muneoserver/domain/user/service/admin/AdminUserServiceImplTest.java @@ -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); diff --git a/src/test/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandlerTest.java b/src/test/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandlerTest.java new file mode 100644 index 0000000..549da80 --- /dev/null +++ b/src/test/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandlerTest.java @@ -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()); + } +} diff --git a/src/test/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPointTest.java b/src/test/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPointTest.java new file mode 100644 index 0000000..45285d0 --- /dev/null +++ b/src/test/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPointTest.java @@ -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()); + } +}