diff --git a/build.gradle.kts b/build.gradle.kts index 63c4806..f998c75 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework:spring-aspects") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-restclient") implementation("org.springframework.boot:spring-boot-starter-validation") diff --git a/src/main/kotlin/kr/co/lokit/api/config/logging/LogMaskingEngine.kt b/src/main/kotlin/kr/co/lokit/api/config/logging/LogMaskingEngine.kt new file mode 100644 index 0000000..79876d1 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/logging/LogMaskingEngine.kt @@ -0,0 +1,86 @@ +package kr.co.lokit.api.config.logging + +import java.util.regex.Pattern + +class LogMaskingEngine private constructor( + patterns: List, +) { + // 1. 모든 패턴을 하나로 합침 ((?i)는 대소문자 무시라는 뜻이야) + private val combinedPattern: Pattern = + Pattern.compile( + patterns.joinToString("|"), + Pattern.CASE_INSENSITIVE or Pattern.MULTILINE, + ) + + fun mask(input: String?): String { + if (input.isNullOrEmpty()) return "" + val matcher = combinedPattern.matcher(input) + val sb = StringBuilder(input.length) + var lastEnd = 0 + + while (matcher.find()) { + sb.append(input, lastEnd, matcher.start()) + // 2. 매칭된 텍스트를 통째로 가져와서 마스킹 로직에 던짐 + sb.append(processMasking(matcher.group())) + lastEnd = matcher.end() + } + sb.append(input, lastEnd, input.length) + return sb.toString() + } + + private fun processMasking(match: String): String = + try { + // 키워드(password, email 등)가 포함되어 있는지 확인 + when { + // [A] 비밀번호 및 토큰 (전체 마스킹) + match.contains("password", true) || match.contains("pwd", true) || + match.contains("token", true) || match.contains("secret", true) -> { + // 기호(: 또는 =)를 기준으로 앞부분(키)만 살리고 뒷부분은 *** + val delimiter = if (match.contains(":")) ":" else "=" + val key = match.substringBefore(delimiter) + "$key$delimiter ***" + } + + // [B] 이메일 (앞 2글자 유지) + match.contains("email", true) -> { + val delimiter = if (match.contains(":")) ":" else "=" + val key = match.substringBefore(delimiter) + val value = match.substringAfter(delimiter).replace("\"", "").trim() + "$key$delimiter \"${value.take(2)}***@${value.substringAfter("@")}\"" + } + + // [C] 전화번호/카드번호 (뒤 4자리 유지) + match.contains("phone", true) || match.contains("cardNumber", true) -> { + val delimiter = if (match.contains(":")) ":" else "=" + val key = match.substringBefore(delimiter) + val value = match.substringAfter(delimiter).replace("\"", "").trim() + val last4 = value.takeLast(4) + val masked = if (match.contains("cardNumber", true)) "****-****-****-$last4" else "***$last4" + "$key$delimiter \"$masked\"" + } + + else -> { + "***" + } + } + } catch (e: Exception) { + "***" // 에러 나면 안전하게 가리기 + } + + companion object { + // [핵심] JSON 형태와 일반 텍스트 형태를 모두 잡는 정규식 세트 + private val SENSITIVE_PATTERNS = + listOf( + // 1. 일반 텍스트용: password : 1234 또는 password=1234 + """(?i)(password|pwd|token|secret|accessToken|refreshToken)\s*[:=]\s*\S+""", + // 2. JSON 형태용: "password":"1234" + """"(password|pwd|token|secret|accessToken|refreshToken)"\s*[:=]\s*"[^"]*"""", + // 3. 이메일, 전화번호, 카드번호용 + """"email"\s*[:=]\s*"[^"]+@[^"]+"""", + """(?i)email\s*[:=]\s*\S+@\S+""", + """"(phone|cardNumber)"\s*[:=]\s*"[^"]*"""", + ) + + fun createDefault() = LogMaskingEngine(SENSITIVE_PATTERNS) + } +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/logging/LoggingConfig.kt b/src/main/kotlin/kr/co/lokit/api/config/logging/LoggingConfig.kt new file mode 100644 index 0000000..959c9a9 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/logging/LoggingConfig.kt @@ -0,0 +1,19 @@ +package kr.co.lokit.api.config.logging + +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered + +@Configuration +class LoggingConfig { + @Bean + fun mdcLoggingFilter(): MdcLoggingFilter = MdcLoggingFilter() + + @Bean + fun mdcLoggingFilterRegistration(filter: MdcLoggingFilter): FilterRegistrationBean = + FilterRegistrationBean(filter).apply { + order = Ordered.HIGHEST_PRECEDENCE + addUrlPatterns("/*") + } +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/logging/MaskingJsonLayout.kt b/src/main/kotlin/kr/co/lokit/api/config/logging/MaskingJsonLayout.kt new file mode 100644 index 0000000..8d33a28 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/logging/MaskingJsonLayout.kt @@ -0,0 +1,42 @@ +package kr.co.lokit.api.config.logging + +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.ThrowableProxyUtil +import ch.qos.logback.core.LayoutBase +import tools.jackson.databind.json.JsonMapper +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +class MaskingJsonLayout : LayoutBase() { + private val objectMapper = JsonMapper.builder().build() + private val dateFormatter = + DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + .withZone(ZoneId.systemDefault()) + private val engine = LogMaskingEngine.createDefault() + + override fun doLayout(event: ILoggingEvent): String { + val logMap = mutableMapOf() + + logMap["timestamp"] = dateFormatter.format(Instant.ofEpochMilli(event.timeStamp)) + logMap["level"] = event.level.toString() + logMap["logger"] = event.loggerName + logMap["thread"] = event.threadName + logMap["message"] = engine.mask(event.formattedMessage) + + // MDC properties + val mdc = event.mdcPropertyMap + if (mdc.isNotEmpty()) { + val maskedMdc = mdc.mapValues { (_, value) -> engine.mask(value) } + logMap["context"] = maskedMdc + } + + // Exception + if (event.throwableProxy != null) { + logMap["exception"] = ThrowableProxyUtil.asString(event.throwableProxy) + } + + return objectMapper.writeValueAsString(logMap) + "\n" + } +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/logging/MaskingPatternLayout.kt b/src/main/kotlin/kr/co/lokit/api/config/logging/MaskingPatternLayout.kt new file mode 100644 index 0000000..192e0d8 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/logging/MaskingPatternLayout.kt @@ -0,0 +1,13 @@ +package kr.co.lokit.api.config.logging + +import ch.qos.logback.classic.PatternLayout +import ch.qos.logback.classic.spi.ILoggingEvent + +class MaskingPatternLayout : PatternLayout() { + private val engine = LogMaskingEngine.createDefault() + + override fun doLayout(event: ILoggingEvent): String { + val message = super.doLayout(event) + return engine.mask(message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/co/lokit/api/config/logging/MdcLoggingFilter.kt b/src/main/kotlin/kr/co/lokit/api/config/logging/MdcLoggingFilter.kt new file mode 100644 index 0000000..dfaa012 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/logging/MdcLoggingFilter.kt @@ -0,0 +1,171 @@ +package kr.co.lokit.api.config.logging + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.http.MediaType +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper +import java.util.UUID + +class MdcLoggingFilter : OncePerRequestFilter() { + private val log = LoggerFactory.getLogger(javaClass) + + companion object { + const val REQUEST_ID = "requestId" + const val REQUEST_URI = "requestUri" + const val REQUEST_METHOD = "requestMethod" + const val CLIENT_IP = "clientIp" + const val STATUS = "status" + const val LATENCY = "latencyMs" + const val REQUEST_BODY = "requestBody" + const val RESPONSE_BODY = "responseBody" + + private const val MAX_BODY_LENGTH = 1000 + private val LOGGABLE_CONTENT_TYPES = + setOf( + MediaType.APPLICATION_JSON_VALUE, + MediaType.APPLICATION_XML_VALUE, + MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE, + ) + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val wrappedRequest = ContentCachingRequestWrapper(request, MAX_BODY_LENGTH) + val wrappedResponse = ContentCachingResponseWrapper(response) + + val start = System.currentTimeMillis() + + try { + MDC.put(REQUEST_ID, generateRequestId()) + MDC.put(REQUEST_URI, request.requestURI) + MDC.put(REQUEST_METHOD, request.method) + MDC.put(CLIENT_IP, getClientIp(request)) + + filterChain.doFilter(wrappedRequest, wrappedResponse) + } finally { + val latency = System.currentTimeMillis() - start + val status = wrappedResponse.status + + MDC.put(STATUS, status.toString()) + MDC.put(LATENCY, latency.toString()) + + val requestBody = getRequestBody(wrappedRequest) + val responseBody = getResponseBody(wrappedResponse) + + if (requestBody.isNotEmpty()) { + MDC.put(REQUEST_BODY, requestBody) + } + if (responseBody.isNotEmpty()) { + MDC.put(RESPONSE_BODY, responseBody) + } + + logRequest(status, latency, requestBody, responseBody) + + wrappedResponse.copyBodyToResponse() + MDC.clear() + } + } + + private fun logRequest( + status: Int, + latency: Long, + requestBody: String, + responseBody: String, + ) { + val sb = StringBuilder() + sb.append("status=$status, latency=${latency}ms") + + if (requestBody.isNotEmpty()) { + sb.append(", request=$requestBody") + } + + if (isErrorStatus(status) && responseBody.isNotEmpty()) { + sb.append(", response=$responseBody") + } + + if (isErrorStatus(status)) { + log.warn("completed: {}", sb.toString()) + } else { + log.info("completed: {}", sb.toString()) + } + } + + private fun isErrorStatus(status: Int): Boolean = status >= 400 + + private fun getRequestBody(request: ContentCachingRequestWrapper): String { + if (!isLoggableContentType(request.contentType)) { + return "" + } + + val content = request.contentAsByteArray + if (content.isEmpty()) { + return "" + } + + val body = String(content, Charsets.UTF_8) + val truncated = + if (body.length > MAX_BODY_LENGTH) { + body.substring(0, MAX_BODY_LENGTH) + "...(truncated)" + } else { + body + } + + return truncated + } + + private fun getResponseBody(response: ContentCachingResponseWrapper): String { + if (!isLoggableContentType(response.contentType)) { + return "" + } + + val content = response.contentAsByteArray + if (content.isEmpty()) { + return "" + } + + val body = String(content, Charsets.UTF_8) + val truncated = + if (body.length > MAX_BODY_LENGTH) { + body.substring(0, MAX_BODY_LENGTH) + "...(truncated)" + } else { + body + } + + return truncated + } + + private fun isLoggableContentType(contentType: String?): Boolean { + if (contentType == null) return false + return LOGGABLE_CONTENT_TYPES.any { contentType.contains(it, ignoreCase = true) } + } + + private fun generateRequestId(): String = + UUID + .randomUUID() + .toString() + .replace("-", "") + .substring(0, 8) + + private fun getClientIp(request: HttpServletRequest): String { + val xForwardedFor = request.getHeader("X-Forwarded-For") + if (!xForwardedFor.isNullOrBlank()) { + return xForwardedFor.split(",").first().trim() + } + + val xRealIp = request.getHeader("X-Real-IP") + if (!xRealIp.isNullOrBlank()) { + return xRealIp + } + + return request.remoteAddr ?: "unknown" + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index ba5f387..74319d5 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,8 +1,10 @@ - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] [%X{clientIp}] [%X{requestMethod} %X{requestUri}] %-5level %logger{36} - %msg%n + @@ -25,8 +27,14 @@ + + + + + + - +