From ccfe90ab0f8029683a04126b80fa638ac35036d0 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 18 Jul 2026 00:48:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EA=B1=B0=EC=A0=88?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EC=83=81=EC=84=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/exception/GlobalExceptionHandler.java | 40 ++++++++++++++++++- .../SecurityAuthenticationTest.java | 28 ++++++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java b/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java index 3d23f3d..12041de 100644 --- a/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java +++ b/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java @@ -1,13 +1,16 @@ package app.exception; +import app.application.HttpClient; import app.core.data.response.ApiResponse; import app.core.data.response.Status; import app.core.data.response.constant.StatusCode; import app.core.exception.BusinessException; import app.security.authentication.CredentialAuthenticationException; import app.security.login.LoginFlowException; +import jakarta.servlet.http.HttpServletRequest; import java.util.Objects; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -32,12 +35,45 @@ public ResponseEntity> handleBusinessException(BusinessExcepti @ExceptionHandler(BadCredentialsException.class) public ResponseEntity> handleBadCredentialsException( - BadCredentialsException ex) { - log.warn("인증 정보 예외 발생: {}", ex.getMessage()); + BadCredentialsException ex, HttpServletRequest request) { StatusCode resolve = StatusCode.resolve(ex.getMessage()); + log.warn( + "[AUTH] 인증 거절 statusCode={} httpStatus={} method={} path={} host={} clientIp={} credential={} cfRay={} exceptionType={}", + resolve.code(), + HttpStatus.OK.value(), + safe(request.getMethod(), 16), + safe(request.getRequestURI(), 256), + safe(request.getServerName(), 255), + safe(HttpClient.getClientIP(request), 64), + credentialState(request), + safe(request.getHeader("CF-Ray"), 64), + ex.getClass().getSimpleName()); return ApiResponse.error(Status.of(resolve)); } + private static String credentialState(HttpServletRequest request) { + String apiKey = request.getHeader("X-API-KEY"); + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (apiKey != null && authorization != null) { + return "MULTIPLE"; + } + if (apiKey != null) { + return apiKey.isBlank() ? "API_KEY_BLANK" : "API_KEY_PRESENT"; + } + if (authorization != null) { + return authorization.isBlank() ? "BEARER_BLANK" : "BEARER_PRESENT"; + } + return "NONE"; + } + + private static String safe(String value, int maxLength) { + if (value == null || value.isBlank()) { + return "none"; + } + String sanitized = value.replaceAll("[\\r\\n\\t]", "_"); + return sanitized.substring(0, Math.min(sanitized.length(), maxLength)); + } + @ExceptionHandler(LoginFlowException.class) public ResponseEntity> handleLoginFlowException(LoginFlowException ex) { log.warn("로그인 인증 예외 발생: {}", ex.getStatusCode().name()); diff --git a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java index 51f24d0..edc3c0b 100644 --- a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java +++ b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java @@ -1,5 +1,6 @@ package app.presentation; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -28,15 +29,19 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @WebMvcTest(controllers = ProfanityController.class) +@ExtendWith(OutputCaptureExtension.class) @Import({ TestConfig.class, SecurityFakeStubConfig.class, @@ -115,22 +120,37 @@ void filter_multipleCredentials_returnsBadRequest() throws Exception { @Test @DisplayName("API 키가 비어있는 경우 4010 UNAUTHORIZED 응답을 반환한다") - void test_4010() throws Exception { + void test_4010(CapturedOutput output) throws Exception { ApiRequest request = quickRequest("test text"); mockMvc .perform( post(REQUEST_URL) .contentType(MediaType.APPLICATION_JSON) .header("X-API-KEY", "") + .header("CF-Connecting-IP", "203.0.113.8") + .header("CF-Ray", "abc123-ICN") .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status.code").value(4010)) .andExpect(jsonPath("$.status.message").value(StatusCode.UNAUTHORIZED.status())); + + assertThat(output.getOut()) + .contains( + "[AUTH] 인증 거절", + "statusCode=4010", + "httpStatus=200", + "method=POST", + "path=/api/v1/filter", + "host=localhost", + "clientIp=203.0.113.8", + "credential=API_KEY_BLANK", + "cfRay=abc123-ICN", + "exceptionType=BadCredentialsException"); } @Test @DisplayName("잘못된 형식의 API 키 요청시 4031 INVALID_API_KEY 응답을 반환한다") - void test_4031() throws Exception { + void test_4031(CapturedOutput output) throws Exception { ApiRequest request = quickRequest("test text"); mockMvc @@ -142,6 +162,10 @@ void test_4031() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.status.code").value(StatusCode.INVALID_API_KEY.code())) .andExpect(jsonPath("$.status.message").value(StatusCode.INVALID_API_KEY.status())); + + assertThat(output.getOut()) + .contains("credential=API_KEY_PRESENT") + .doesNotContain("invalid-api-key"); } @Test