From d53e5ddbed2b159cba520e1dbd6543c13689e8be Mon Sep 17 00:00:00 2001 From: seoshinehyo Date: Sun, 17 May 2026 20:56:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[fix]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EA=B6=8C=ED=95=9C=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/SecurityConfig.java | 14 +++++++- .../handler/CustomAccessDeniedHandler.java | 36 +++++++++++++++++++ .../CustomAuthenticationEntryPoint.java | 36 +++++++++++++++++++ .../CustomAccessDeniedHandlerTest.java | 31 ++++++++++++++++ .../CustomAuthenticationEntryPointTest.java | 35 ++++++++++++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPoint.java create mode 100644 src/test/java/com/example/muneoserver/global/security/handler/CustomAccessDeniedHandlerTest.java create mode 100644 src/test/java/com/example/muneoserver/global/security/handler/CustomAuthenticationEntryPointTest.java 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/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()); + } +} From 8a4511710bd33975807ab7195e30ba4834ee3bf4 Mon Sep 17 00:00:00 2001 From: seoshinehyo Date: Sun, 17 May 2026 20:59:45 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[fix]=20Swagger=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/docs/AdminUserControllerDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..d302c52 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 @@ -231,7 +231,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 ); From be9ce37dcf2858769519b7665f7deabc5005b114 Mon Sep 17 00:00:00 2001 From: seoshinehyo Date: Sun, 17 May 2026 21:04:12 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[fix]=20Swagger=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/docs/AdminUserControllerDocs.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 d302c52..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, @@ -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( From 31c17d8dd687c280379507297c428dd260b49fff Mon Sep 17 00:00:00 2001 From: seoshinehyo Date: Sun, 17 May 2026 21:04:29 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[add]=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/muneoserver/global/error/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) 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", "소셜 회원가입 티켓이 만료되었습니다."), From b66f38e31ddc8a406f0f482dc75026cdc4c24785 Mon Sep 17 00:00:00 2001 From: seoshinehyo Date: Sun, 17 May 2026 21:04:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[fix]=20=EA=B6=8C=ED=95=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/admin/AdminUserServiceImpl.java | 4 ++++ .../service/admin/AdminUserServiceImplTest.java | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) 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/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);