Skip to content

Commit 1c20dc2

Browse files
authored
feat: client info 정보 추출 interceptor 추가 (#372)
* feat: create client info interceptor * feat: add client info interceptor to webconfig * refactor: userId -> clientId * feat: seperate client info dto from interceptor * refactor: setter as internal * feat: seperate client info holder to context package * refactor: remove redundant let * feat: seperate info context inteface * comment: add kdoc comments * fix: fix getter override issue * test: add test for client info interceptor
1 parent e7428bd commit 1c20dc2

File tree

5 files changed

+202
-1
lines changed

5 files changed

+202
-1
lines changed

src/main/kotlin/com/wafflestudio/csereal/common/config/WebConfig.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package com.wafflestudio.csereal.common.config
22

3+
import com.wafflestudio.csereal.common.interceptor.ClientInfoInterceptor
34
import com.wafflestudio.csereal.common.properties.EndpointProperties
45
import org.springframework.boot.context.properties.EnableConfigurationProperties
56
import org.springframework.context.annotation.Configuration
67
import org.springframework.web.servlet.config.annotation.CorsRegistry
8+
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
79
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
810

911
@Configuration
1012
@EnableConfigurationProperties(EndpointProperties::class)
1113
class WebConfig(
12-
private val endpointProperties: EndpointProperties
14+
private val endpointProperties: EndpointProperties,
15+
private val clientInfoInterceptor: ClientInfoInterceptor
1316
) : WebMvcConfigurer {
1417

1518
override fun addCorsMappings(registry: CorsRegistry) {
@@ -20,4 +23,8 @@ class WebConfig(
2023
.allowCredentials(true)
2124
.maxAge(3000)
2225
}
26+
27+
override fun addInterceptors(registry: InterceptorRegistry) {
28+
registry.addInterceptor(clientInfoInterceptor)
29+
}
2330
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.wafflestudio.csereal.common.context
2+
3+
import com.wafflestudio.csereal.common.dto.ClientInfo
4+
import org.springframework.stereotype.Component
5+
import org.springframework.web.context.annotation.RequestScope
6+
7+
/**
8+
* Provides access to client information within the lifetime of a single HTTP request.
9+
*
10+
* Implementations are expected to be request-scoped so that each request receives
11+
* its own instance and associated [ClientInfo].
12+
*/
13+
interface ClientInfoContext {
14+
val clientInfo: ClientInfo
15+
}
16+
17+
/**
18+
* Request-scoped holder that stores client information for the current request.
19+
*
20+
* This concrete implementation is intended to be populated by an interceptor
21+
* (e.g., [com.wafflestudio.csereal.common.interceptor.ClientInfoInterceptor])
22+
* before controller logic executes.
23+
*/
24+
@Component
25+
@RequestScope
26+
class ClientInfoHolder : ClientInfoContext {
27+
/**
28+
* Client information captured for the current request.
29+
*
30+
* This property is initialized by the request processing pipeline, typically
31+
* in [com.wafflestudio.csereal.common.interceptor.ClientInfoInterceptor].
32+
*/
33+
override lateinit var clientInfo: ClientInfo
34+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.wafflestudio.csereal.common.dto
2+
3+
import java.net.InetAddress
4+
import java.util.*
5+
6+
/**
7+
* Client identification details captured from an HTTP request.
8+
*
9+
* @property ipAddress IP address of the client, resolved to [InetAddress].
10+
* @property clientId Optional client identifier as a [UUID]. If absent or invalid,
11+
* it remains `null`.
12+
*/
13+
data class ClientInfo(
14+
val ipAddress: InetAddress,
15+
val clientId: UUID? = null
16+
) {
17+
/**
18+
* Creates a [ClientInfo] from string representations.
19+
*
20+
* - [ipAddress]: IPv4/IPv6 string, resolved via [InetAddress.getByName].
21+
* - [clientId]: Optional UUID string; invalid values are ignored and treated as `null`.
22+
*/
23+
constructor(ipAddress: String, clientId: String?) : this(
24+
ipAddress = ipAddressOf(ipAddress),
25+
clientId = clientId?.let { clientIdOfOrNull(it) }
26+
)
27+
28+
/**
29+
* Indicates whether this object contains a usable client identifier.
30+
*
31+
* @return `true` if [clientId] is not `null`, otherwise `false`.
32+
*/
33+
fun isValid() = clientId != null
34+
}
35+
36+
private fun ipAddressOf(ipAddress: String) = InetAddress.getByName(ipAddress)
37+
private fun clientIdOfOrNull(clientId: String) = runCatching { UUID.fromString(clientId) }.getOrNull()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.wafflestudio.csereal.common.interceptor
2+
3+
import com.wafflestudio.csereal.common.context.ClientInfoHolder
4+
import com.wafflestudio.csereal.common.dto.ClientInfo
5+
import jakarta.servlet.http.HttpServletRequest
6+
import jakarta.servlet.http.HttpServletResponse
7+
import org.slf4j.LoggerFactory
8+
import org.springframework.context.annotation.Configuration
9+
import org.springframework.web.servlet.HandlerInterceptor
10+
11+
private const val CLIENT_INFO_HEADER = "X-Client-Id"
12+
private const val FORWARDED_FOR_HEADER = "X-Forwarded-For"
13+
14+
/**
15+
* Intercepts incoming HTTP requests to extract client information.
16+
*
17+
* The interceptor resolves the client's IP address using the `X-Forwarded-For` header
18+
* when available, falling back to the remote address. It also reads an optional
19+
* `X-Client-Id` header and attempts to parse it as a UUID. The captured data is stored
20+
* in a request-scoped [ClientInfoHolder] for downstream usage.
21+
*/
22+
@Configuration
23+
class ClientInfoInterceptor(
24+
private val clientInfoHolder: ClientInfoHolder
25+
) : HandlerInterceptor {
26+
private val logger = LoggerFactory.getLogger(this::class.java)
27+
28+
override fun preHandle(
29+
request: HttpServletRequest,
30+
response: HttpServletResponse,
31+
handler: Any
32+
): Boolean {
33+
val ipAddress: String = request.getHeader(FORWARDED_FOR_HEADER)
34+
?.split(",")
35+
?.map { it.trim() }
36+
?.firstOrNull()
37+
?: request.remoteAddr
38+
val clientId: String? = request.getHeader(CLIENT_INFO_HEADER)
39+
40+
val clientInfo = ClientInfo(ipAddress, clientId)
41+
logger.info("client info: {}", clientInfo)
42+
43+
// since only ip address can be used, we set the clientInfo even if it is invalid (no clientId)
44+
clientInfoHolder.clientInfo = clientInfo
45+
46+
return true
47+
}
48+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.wafflestudio.csereal.common.interceptor
2+
3+
import com.wafflestudio.csereal.common.context.ClientInfoHolder
4+
import io.kotest.core.spec.style.BehaviorSpec
5+
import io.kotest.matchers.booleans.shouldBeFalse
6+
import io.kotest.matchers.booleans.shouldBeTrue
7+
import io.kotest.matchers.nulls.shouldBeNull
8+
import io.kotest.matchers.shouldBe
9+
import io.mockk.every
10+
import io.mockk.mockk
11+
import jakarta.servlet.http.HttpServletRequest
12+
import jakarta.servlet.http.HttpServletResponse
13+
import java.util.UUID
14+
15+
class ClientInfoInterceptorTest : BehaviorSpec({
16+
17+
Given("ClientInfoInterceptor with a fresh request-scoped holder") {
18+
val clientInfoHolder = ClientInfoHolder()
19+
val interceptor = ClientInfoInterceptor(clientInfoHolder)
20+
21+
When("X-Forwarded-For contains multiple IPs and X-Client-Id is a valid UUID") {
22+
val request = mockk<HttpServletRequest>()
23+
val response = mockk<HttpServletResponse>(relaxed = true)
24+
val validUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000")
25+
26+
every { request.getHeader("X-Forwarded-For") } returns "203.0.113.7, 70.41.3.18, 150.172.238.178"
27+
every { request.getHeader("X-Client-Id") } returns validUuid.toString()
28+
every { request.remoteAddr } returns "198.51.100.23" // should be ignored due to XFF
29+
30+
interceptor.preHandle(request, response, Any()) shouldBe true
31+
32+
Then("it should pick the first X-Forwarded-For IP and parse the UUID") {
33+
clientInfoHolder.clientInfo.ipAddress.hostAddress shouldBe "203.0.113.7"
34+
clientInfoHolder.clientInfo.clientId shouldBe validUuid
35+
clientInfoHolder.clientInfo.isValid().shouldBeTrue()
36+
}
37+
}
38+
39+
When("X-Forwarded-For is missing and X-Client-Id header is missing") {
40+
val request = mockk<HttpServletRequest>()
41+
val response = mockk<HttpServletResponse>(relaxed = true)
42+
43+
every { request.getHeader("X-Forwarded-For") } returns null
44+
every { request.getHeader("X-Client-Id") } returns null
45+
every { request.remoteAddr } returns "192.0.2.1"
46+
47+
interceptor.preHandle(request, response, Any()) shouldBe true
48+
49+
Then("it should fallback to remoteAddr and have no clientId") {
50+
clientInfoHolder.clientInfo.ipAddress.hostAddress shouldBe "192.0.2.1"
51+
clientInfoHolder.clientInfo.clientId.shouldBeNull()
52+
clientInfoHolder.clientInfo.isValid().shouldBeFalse()
53+
}
54+
}
55+
56+
When("X-Client-Id is present but invalid UUID") {
57+
val request = mockk<HttpServletRequest>()
58+
val response = mockk<HttpServletResponse>(relaxed = true)
59+
60+
every { request.getHeader("X-Forwarded-For") } returns "2001:db8::1, 203.0.113.9"
61+
every { request.getHeader("X-Client-Id") } returns "not-a-uuid"
62+
every { request.remoteAddr } returns "192.0.2.55"
63+
64+
interceptor.preHandle(request, response, Any()) shouldBe true
65+
66+
Then("it should ignore invalid UUID and still capture IP from XFF") {
67+
// normalize IPv6 representation because InetAddress may compress zeros
68+
val captured = clientInfoHolder.clientInfo.ipAddress.hostAddress
69+
(captured == "2001:db8:0:0:0:0:0:1" || captured == "2001:db8::1") shouldBe true
70+
clientInfoHolder.clientInfo.clientId.shouldBeNull()
71+
clientInfoHolder.clientInfo.isValid().shouldBeFalse()
72+
}
73+
}
74+
}
75+
})

0 commit comments

Comments
 (0)