diff --git a/build.gradle.kts b/build.gradle.kts index 4361a4b..757b76e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,7 @@ val bouncyCastleVersion = "1.78" val awsSdkVersion = "2.27.0" val springDocVersion = "2.6.0" val jasyptVersion = "3.0.5" +val logstashEncoderVersion = "8.0" val kotestVersion = "5.9.1" val kotestExtensionsVersion = "1.3.0" val mockkVersion = "1.13.10" @@ -77,6 +78,9 @@ dependencies { // Jasypt (암호화) implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:$jasyptVersion") + // Logback JSON Encoder (구조화된 로그 출력) + implementation("net.logstash.logback:logstash-logback-encoder:$logstashEncoderVersion") + // JPA implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("org.postgresql:postgresql") diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt index ee10a27..9696a2a 100644 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt @@ -1,5 +1,6 @@ package com.yapp2app.auth.infra.security.config +import com.yapp2app.auth.infra.security.filter.AuthMdcFilter import com.yapp2app.auth.infra.security.filter.JwtAuthenticationFilter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -52,5 +53,6 @@ class SecurityConfig(private val corsConfigurationSource: CorsConfigurationSourc it.anyRequest().authenticated() } .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + .addFilterAfter(AuthMdcFilter(), JwtAuthenticationFilter::class.java) .build() } diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/filter/AuthMdcFilter.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/filter/AuthMdcFilter.kt new file mode 100644 index 0000000..e69cffa --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/filter/AuthMdcFilter.kt @@ -0,0 +1,45 @@ +package com.yapp2app.auth.infra.security.filter + +import com.yapp2app.auth.infra.security.token.UserPrincipal +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +/** + * fileName : AuthMdcFilter + * author : koo + * date : 2025. 12. 31. + * description : 인증 후에 실행되어 인증된 사용자 정보를 MDC에 추가하는 필터 + * SecurityContext에서 인증 정보를 가져와 userId를 MDC에 설정 + * SecurityFilterChain 내부에서 JwtAuthenticationFilter 다음에 실행 + */ +class AuthMdcFilter : OncePerRequestFilter() { + + companion object { + const val USER_ID = "userId" + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + // SecurityContext에서 인증 정보 가져오기 + val authentication = SecurityContextHolder.getContext().authentication + + // 인증된 사용자인 경우 MDC에 userId 추가 + if (authentication != null && authentication.isAuthenticated && authentication.principal != "anonymousUser") { + val userPrincipal = authentication.principal as UserPrincipal + + // DB상 name을 가져오는 코드 (택 1) + val userId = userPrincipal.id.toString() + + MDC.put(USER_ID, userId) + } + + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/com/yapp2app/common/filter/RequestMdcFilter.kt b/src/main/kotlin/com/yapp2app/common/filter/RequestMdcFilter.kt new file mode 100644 index 0000000..4f357af --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/filter/RequestMdcFilter.kt @@ -0,0 +1,86 @@ +package com.yapp2app.common.filter + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +/** + * fileName : RequestMdcFilter + * author : koo + * date : 2025. 12. 31. + * description : 인증 전에 실행되어 요청별 기본 정보를 MDC에 설정하는 필터 + * Request ID, URI, Method, Client IP 등을 추가 + */ +class RequestMdcFilter : OncePerRequestFilter() { + + companion object { + const val REQUEST_ID = "requestId" + const val REQUEST_URI = "requestUri" + const val REQUEST_METHOD = "requestMethod" + const val CLIENT_IP = "clientIp" + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + // Request ID 설정 (헤더에서 가져오거나 새로 생성) + val requestId = request.getHeader("X-Request-ID") ?: generateRequestId() + MDC.put(REQUEST_ID, requestId) + + // 요청 정보 설정 + MDC.put(REQUEST_URI, request.requestURI) + MDC.put(REQUEST_METHOD, request.method) + MDC.put(CLIENT_IP, getClientIp(request)) + + // Response 헤더에 Request ID 추가 + response.setHeader("X-Request-ID", requestId) + + filterChain.doFilter(request, response) + } finally { + // MDC 정리 (메모리 누수 방지) + // 가장 먼저 실행되고 가장 마지막에 종료되므로 여기서 전체 MDC 정리 + MDC.clear() + } + } + + /** + * 고유한 Request ID 생성 + */ + private fun generateRequestId(): String = UUID.randomUUID().toString().replace("-", "") + + /** + * 클라이언트 IP 주소 추출 + * 프록시나 로드밸런서를 거치는 경우 X-Forwarded-For 헤더에서 실제 클라이언트 IP를 추출 + */ + private fun getClientIp(request: HttpServletRequest): String { + val headers = listOf( + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR", + ) + + for (header in headers) { + val ip = request.getHeader(header) + if (!ip.isNullOrBlank() && ip != "unknown") { + // X-Forwarded-For는 여러 IP가 콤마로 구분될 수 있음 (첫 번째가 실제 클라이언트 IP) + return ip.split(",").firstOrNull()?.trim() ?: ip + } + } + + return request.remoteAddr ?: "unknown" + } +} diff --git a/src/main/kotlin/com/yapp2app/common/filter/ServletFilterConfig.kt b/src/main/kotlin/com/yapp2app/common/filter/ServletFilterConfig.kt new file mode 100644 index 0000000..a3c0967 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/filter/ServletFilterConfig.kt @@ -0,0 +1,25 @@ +package com.yapp2app.common.filter + +import org.springframework.boot.autoconfigure.security.SecurityProperties +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * fileName : ServletFilterConfig + * author : koo + * date : 2026. 1. 8. 오후 3:44 + * description : + */ +@Configuration +class ServletFilterConfig { + + @Bean + fun requestMdcFilter(): RequestMdcFilter = RequestMdcFilter() + + @Bean + fun requestMdcFilterRegistration(filter: RequestMdcFilter): FilterRegistrationBean = + FilterRegistrationBean(filter).apply { + order = SecurityProperties.DEFAULT_FILTER_ORDER - 1 + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..9eb82a1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN_DEV} + UTF-8 + + + + + + + + yyyy-MM-dd'T'HH:mm:ss.SSSXXX + + + requestId + userId + requestMethod + requestUri + clientIp + + + + timestamp + [ignore] + [ignore] + + + + {"application":"yapp"} + + + + 30 + 2048 + 20 + ^sun\.reflect\..*\.invoke + ^net\.sf\.cglib\.proxy\.MethodProxy\.invoke + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/yapp2app/auth/infra/security/filter/AuthMdcFilterTest.kt b/src/test/kotlin/com/yapp2app/auth/infra/security/filter/AuthMdcFilterTest.kt new file mode 100644 index 0000000..87e722d --- /dev/null +++ b/src/test/kotlin/com/yapp2app/auth/infra/security/filter/AuthMdcFilterTest.kt @@ -0,0 +1,195 @@ +package com.yapp2app.auth.infra.security.filter + +import com.yapp2app.common.filter.RequestMdcFilter +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.slf4j.MDC +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder + +@DisplayName("AuthMdcFilter 테스트") +class AuthMdcFilterTest { + + private val filter = AuthMdcFilter() + + @AfterEach + fun tearDown() { + // 각 테스트 후 MDC 및 SecurityContext 정리 + MDC.clear() + SecurityContextHolder.clearContext() + } + + @Test + @DisplayName("인증된 사용자의 userId가 MDC에 설정된다") + fun givenAuthenticatedUser_whenFilterExecutes_thenSetsUserIdInMdc() { + // Given + val expectedUserId = "user-12345" + val authentication = createAuthentication(expectedUserId, authenticated = true) + SecurityContextHolder.getContext().authentication = authentication + + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + Assertions.assertThat(filterChain.capturedUserId).isEqualTo(expectedUserId) + } + + @Test + @DisplayName("익명 사용자일 때 userId가 MDC에 설정되지 않는다") + fun givenAnonymousUser_whenFilterExecutes_thenDoesNotSetUserId() { + // Given + val authentication = createAuthentication("anonymousUser", authenticated = true) + SecurityContextHolder.getContext().authentication = authentication + + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + Assertions.assertThat(filterChain.capturedUserId).isNull() + } + + @Test + @DisplayName("인증되지 않은 사용자일 때 userId가 MDC에 설정되지 않는다") + fun givenUnauthenticatedUser_whenFilterExecutes_thenDoesNotSetUserId() { + // Given + val authentication = createAuthentication("user-12345", authenticated = false) + SecurityContextHolder.getContext().authentication = authentication + + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + Assertions.assertThat(filterChain.capturedUserId).isNull() + } + + @Test + @DisplayName("인증 정보가 없을 때 userId가 MDC에 설정되지 않는다") + fun givenNoAuthentication_whenFilterExecutes_thenDoesNotSetUserId() { + // Given - SecurityContext에 인증 정보 없음 + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + Assertions.assertThat(filterChain.capturedUserId).isNull() + } + + @Test + @DisplayName("필터 체인이 정상적으로 실행된다") + fun givenRequest_whenFilterExecutes_thenContinuesFilterChain() { + // Given + val authentication = createAuthentication("user-12345", authenticated = true) + SecurityContextHolder.getContext().authentication = authentication + + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = MockFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then - 필터 체인이 실행되었는지 확인 + Assertions.assertThat(filterChain.request).isEqualTo(request) + Assertions.assertThat(filterChain.response).isEqualTo(response) + } + + @Test + @DisplayName("권한이 있는 인증된 사용자의 userId가 MDC에 설정된다") + fun givenAuthenticatedUserWithAuthorities_whenFilterExecutes_thenSetsUserIdInMdc() { + // Given + val expectedUserId = "admin-99999" + val authorities = listOf( + SimpleGrantedAuthority("ROLE_ADMIN"), + SimpleGrantedAuthority("ROLE_USER"), + ) + val authentication = UsernamePasswordAuthenticationToken( + expectedUserId, + "password", + authorities, + ) + SecurityContextHolder.getContext().authentication = authentication + + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + Assertions.assertThat(filterChain.capturedUserId).isEqualTo(expectedUserId) + } + + @Test + @DisplayName("다른 필터에서 설정한 MDC 값을 유지한다") + fun givenExistingMdcValues_whenFilterExecutes_thenPreservesExistingValues() { + // Given + val expectedRequestId = "request-id-12345" + MDC.put(RequestMdcFilter.Companion.REQUEST_ID, expectedRequestId) + + val expectedUserId = "user-12345" + val authentication = createAuthentication(expectedUserId, authenticated = true) + SecurityContextHolder.getContext().authentication = authentication + + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + Assertions.assertThat(filterChain.capturedRequestId).isEqualTo(expectedRequestId) + Assertions.assertThat(filterChain.capturedUserId).isEqualTo(expectedUserId) + } + + /** + * 테스트용 Authentication 객체 생성 + */ + private fun createAuthentication(username: String, authenticated: Boolean): UsernamePasswordAuthenticationToken = + if (authenticated) { + // authenticated = true: authorities를 포함하는 생성자 사용 + UsernamePasswordAuthenticationToken(username, "password", emptyList()) + } else { + // authenticated = false: authorities를 포함하지 않는 생성자 사용 + UsernamePasswordAuthenticationToken(username, "password") + } + + /** + * FilterChain 실행 중 MDC 값을 캡처하는 테스트용 FilterChain + */ + private class TestFilterChain : FilterChain { + var capturedUserId: String? = null + var capturedRequestId: String? = null + + override fun doFilter(request: ServletRequest?, response: ServletResponse?) { + // FilterChain 실행 시점에 MDC 값 캡처 + capturedUserId = MDC.get(AuthMdcFilter.USER_ID) + capturedRequestId = MDC.get(RequestMdcFilter.Companion.REQUEST_ID) + } + } +} diff --git a/src/test/kotlin/com/yapp2app/common/filter/RequestMdcFilterTest.kt b/src/test/kotlin/com/yapp2app/common/filter/RequestMdcFilterTest.kt new file mode 100644 index 0000000..9ba8128 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/common/filter/RequestMdcFilterTest.kt @@ -0,0 +1,186 @@ +package com.yapp2app.common.filter + +import jakarta.servlet.FilterChain +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.slf4j.MDC +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +@DisplayName("RequestMdcFilter 테스트") +class RequestMdcFilterTest { + + private val filter = RequestMdcFilter() + + @AfterEach + fun tearDown() { + // 각 테스트 후 MDC 정리 + MDC.clear() + } + + @Test + @DisplayName("Request ID가 헤더에 없을 때 자동으로 생성한다") + fun givenNoRequestIdHeader_whenFilterExecutes_thenGeneratesRequestId() { + // Given + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + val requestId = filterChain.capturedRequestId + assertThat(requestId).isNotNull() + assertThat(requestId).hasSize(32) // UUID without hyphens + assertThat(response.getHeader("X-Request-ID")).isEqualTo(requestId) + } + + @Test + @DisplayName("Request ID가 헤더에 있을 때 해당 값을 사용한다") + fun givenRequestIdInHeader_whenFilterExecutes_thenUsesHeaderValue() { + // Given + val expectedRequestId = "test-request-id-12345" + val request = MockHttpServletRequest().apply { + addHeader("X-Request-ID", expectedRequestId) + } + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + val actualRequestId = filterChain.capturedRequestId + assertThat(actualRequestId).isEqualTo(expectedRequestId) + assertThat(response.getHeader("X-Request-ID")).isEqualTo(expectedRequestId) + } + + @Test + @DisplayName("요청 URI가 MDC에 설정된다") + fun givenRequest_whenFilterExecutes_thenSetsRequestUriInMdc() { + // Given + val expectedUri = "/api/users/123" + val request = MockHttpServletRequest().apply { + requestURI = expectedUri + } + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + assertThat(filterChain.capturedRequestUri).isEqualTo(expectedUri) + } + + @Test + @DisplayName("요청 메소드가 MDC에 설정된다") + fun givenRequest_whenFilterExecutes_thenSetsRequestMethodInMdc() { + // Given + val expectedMethod = "POST" + val request = MockHttpServletRequest().apply { + method = expectedMethod + } + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + assertThat(filterChain.capturedRequestMethod).isEqualTo(expectedMethod) + } + + @Test + @DisplayName("클라이언트 IP가 MDC에 설정된다") + fun givenRequest_whenFilterExecutes_thenSetsClientIpInMdc() { + // Given + val expectedIp = "192.168.1.100" + val request = MockHttpServletRequest().apply { + remoteAddr = expectedIp + } + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + assertThat(filterChain.capturedClientIp).isEqualTo(expectedIp) + } + + @Test + @DisplayName("X-Forwarded-For 헤더가 있을 때 실제 클라이언트 IP를 추출한다") + fun givenXForwardedForHeader_whenFilterExecutes_thenExtractsRealClientIp() { + // Given + val realClientIp = "203.0.113.195" + val proxyIp = "192.168.1.1" + val request = MockHttpServletRequest().apply { + addHeader("X-Forwarded-For", "$realClientIp, $proxyIp") + remoteAddr = proxyIp + } + val response = MockHttpServletResponse() + val filterChain = TestFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + assertThat(filterChain.capturedClientIp).isEqualTo(realClientIp) + } + + @Test + @DisplayName("필터 체인 실행 후 MDC가 정리된다") + fun givenFilterExecution_whenFilterCompletes_thenClearsMdc() { + // Given + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = MockFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then - MDC가 비어있어야 함 + assertThat(MDC.get(RequestMdcFilter.REQUEST_ID)).isNull() + assertThat(MDC.get(RequestMdcFilter.REQUEST_URI)).isNull() + assertThat(MDC.get(RequestMdcFilter.REQUEST_METHOD)).isNull() + assertThat(MDC.get(RequestMdcFilter.CLIENT_IP)).isNull() + } + + @Test + @DisplayName("Response 헤더에 Request ID가 추가된다") + fun givenRequest_whenFilterExecutes_thenAddsRequestIdToResponseHeader() { + // Given + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val filterChain = MockFilterChain() + + // When + filter.doFilter(request, response, filterChain) + + // Then + assertThat(response.getHeader("X-Request-ID")).isNotNull() + } + + /** + * FilterChain 실행 중 MDC 값을 캡처하는 테스트용 FilterChain + */ + private class TestFilterChain : FilterChain { + var capturedRequestId: String? = null + var capturedRequestUri: String? = null + var capturedRequestMethod: String? = null + var capturedClientIp: String? = null + + override fun doFilter(request: jakarta.servlet.ServletRequest?, response: jakarta.servlet.ServletResponse?) { + // FilterChain 실행 시점에 MDC 값 캡처 + capturedRequestId = MDC.get(RequestMdcFilter.REQUEST_ID) + capturedRequestUri = MDC.get(RequestMdcFilter.REQUEST_URI) + capturedRequestMethod = MDC.get(RequestMdcFilter.REQUEST_METHOD) + capturedClientIp = MDC.get(RequestMdcFilter.CLIENT_IP) + } + } +}