Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
# cp .env.example .env 후 값을 바꿔서 쓰세요. (.env는 git에 올리지 않습니다)
JWT_SECRET=change-me-to-a-long-random-secret-key-at-least-32-bytes

# rush-gateway: actuator 관리용 엔드포인트(health 제외)에 접근할 수 있는 IP 대역(CIDR, 콤마 구분)
GATEWAY_ADMIN_ALLOWED_IPS=127.0.0.1/32,::1/128
# rush-gateway: 앞단 LB/프록시 대역(CIDR). 이 대역에서 온 요청만 X-Forwarded-For를 믿습니다.
TRUSTED_PROXIES=
# rush-gateway: CORS를 허용할 Origin(콤마 구분)
CORS_ALLOWED_ORIGINS=
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ services:
SP_URL: http://rush-sp:8086
LIVE_URL: ws://rush-live:8087
CHAT_URL: ws://rush-chat:8088
GATEWAY_ADMIN_ALLOWED_IPS: ${GATEWAY_ADMIN_ALLOWED_IPS:-127.0.0.1/32,::1/128}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
ports: ["8080:8080"] # 외부에 열리는 유일한 포트
depends_on:
rush-config: { condition: service_healthy }
Expand Down
15 changes: 15 additions & 0 deletions rush-config/src/main/resources/config/rush-gateway.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
# rush-gateway 전용 설정 (라우팅은 rush-gateway/application.yml 참고)
server:
port: 8080

gateway:
# actuator 관리용 엔드포인트(health 제외)에 접근할 수 있는 IP 대역(CIDR, 콤마 구분)
admin-allowed-ips: ${GATEWAY_ADMIN_ALLOWED_IPS:127.0.0.1/32,::1/128}
# 앞단 LB/프록시 대역. 이 대역에서 온 요청만 X-Forwarded-For를 믿습니다.
trusted-proxies: ${TRUSTED_PROXIES:}
# CORS를 허용할 Origin(콤마 구분). 비어 있으면 CORS를 허용하지 않습니다.
cors-allowed-origins: ${CORS_ALLOWED_ORIGINS:}

# 공통 application.yml의 exposure를 덮어씁니다. health 외에는 허용 IP에서만 열립니다.
management:
endpoints:
web:
exposure:
include: health, info, gateway, circuitbreakers
13 changes: 13 additions & 0 deletions rush-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ WebSocket을 제외한 라우트는 서킷브레이커를 거칩니다. 서비

모든 요청에 `X-Request-Id`를 새로 붙여 서비스와 응답에 넘기고, 메서드·경로·상태·소요 시간·사용자 ID를 한 줄로 남깁니다.

## 접근 제어

- JWT 검증은 `JwtAuthenticationFilter`가 라우트마다 합니다. Spring Security는 경로 접근 규칙, CORS, 보안 응답 헤더만 맡습니다.
- actuator는 `/actuator/health`만 공개합니다. `gateway`(라우트 조회만), `circuitbreakers`, `info`는 `GATEWAY_ADMIN_ALLOWED_IPS`에 속한 IP에서만 열리고, 그 외에는 `403`을 돌려줍니다.
- 클라이언트 IP는 연결 IP로 판별합니다. 연결 IP가 `TRUSTED_PROXIES`에 속할 때만 `X-Forwarded-For`를 읽으므로, 헤더를 위조해도 IP 제한을 우회할 수 없습니다.
- docker compose로 띄우면 호스트에서 보낸 요청은 도커 브리지 IP로 들어옵니다. 호스트에서 관리용 엔드포인트를 보려면 `GATEWAY_ADMIN_ALLOWED_IPS`에 그 대역을 추가하세요.

| 환경변수 | 기본값 | 설명 |
|---|---|---|
| `GATEWAY_ADMIN_ALLOWED_IPS` | `127.0.0.1/32,::1/128` | 관리용 actuator 허용 대역 |
| `TRUSTED_PROXIES` | 없음 | 앞단 LB/프록시 대역 |
| `CORS_ALLOWED_ORIGINS` | 없음 | CORS 허용 Origin |

## 실행

```bash
Expand Down
1 change: 1 addition & 0 deletions rush-gateway/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies {
implementation(libs.spring.cloud.starter.config)
implementation(libs.spring.cloud.starter.netflix.eureka.client)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.security)
implementation(libs.jjwt.api)
runtimeOnly(libs.jjwt.impl)
runtimeOnly(libs.jjwt.jackson)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.rush.gateway

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class GatewayApplication

fun main(args: Array<String>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.rush.gateway.config

import org.springframework.boot.context.properties.ConfigurationProperties

/**
* 게이트웨이 전용 설정입니다. 값은 rush-config의 rush-gateway.yml에서 오고,
* config 서버 없이 뜰 때를 위해 기본값을 둡니다.
*/
@ConfigurationProperties("gateway")
data class GatewayProperties(
// actuator 관리용 엔드포인트(health 제외)에 접근할 수 있는 IP 대역(CIDR)
val adminAllowedIps: List<String> = listOf("127.0.0.1/32", "::1/128"),
// 이 대역에서 온 요청만 X-Forwarded-For를 믿습니다. 비어 있으면 연결 IP만 씁니다.
val trustedProxies: List<String> = emptyList(),
// CORS를 허용할 Origin. 비어 있으면 CORS 헤더를 붙이지 않습니다.
val corsAllowedOrigins: List<String> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ data class ErrorResponse(
private fun messageOf(status: HttpStatus) =
when (status) {
HttpStatus.UNAUTHORIZED -> "인증이 필요합니다."
HttpStatus.FORBIDDEN -> "접근 권한이 없습니다."
HttpStatus.NOT_FOUND -> "요청한 경로를 찾을 수 없습니다."
HttpStatus.SERVICE_UNAVAILABLE -> "일시적으로 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요."
HttpStatus.GATEWAY_TIMEOUT -> "서비스 응답이 지연되고 있습니다. 잠시 후 다시 시도해 주세요."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.rush.gateway.security

import com.rush.gateway.config.GatewayProperties
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.stereotype.Component

/**
* 클라이언트 IP를 판별합니다. IP 제한, 접근 로그, 헤더 정화가 모두 이 결과를 씁니다.
*
* 연결 IP가 신뢰 프록시일 때만 X-Forwarded-For를 오른쪽부터 읽어
* 신뢰 프록시가 아닌 첫 IP를 클라이언트로 봅니다. 그 외에는 연결 IP를 그대로 씁니다.
*/
@Component
class ClientIpResolver(
properties: GatewayProperties,
) {
private val trustedProxies = IpRangeMatcher(properties.trustedProxies)

fun resolve(request: ServerHttpRequest): String {
val remote = remoteIp(request) ?: UNKNOWN
if (!trustedProxies.matches(remote)) return remote

var client = remote
val hops =
request.headers[X_FORWARDED_FOR]
.orEmpty()
.flatMap { it.split(',') }
.map { it.trim() }
for (hop in hops.asReversed()) {
if (!IpRangeMatcher.isIpLiteral(hop)) break
client = hop
if (!trustedProxies.matches(hop)) break
}
return client
}

/** 요청이 신뢰 프록시를 거쳐 왔는지 여부입니다. */
fun isFromTrustedProxy(request: ServerHttpRequest): Boolean = trustedProxies.matches(remoteIp(request))

// IPv6 연결은 "0:0:0:0:0:0:0:1%0"처럼 scope id가 붙으므로 떼어 냅니다.
private fun remoteIp(request: ServerHttpRequest): String? =
request.remoteAddress
?.address
?.hostAddress
?.substringBefore('%')

companion object {
const val X_FORWARDED_FOR = "X-Forwarded-For"
private const val UNKNOWN = "unknown"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.rush.gateway.security

import org.springframework.security.web.util.matcher.IpAddressMatcher
import java.net.InetAddress

/**
* CIDR 목록 중 하나라도 IP에 맞는지 봅니다.
* IP 리터럴이 아닌 값은 DNS 조회를 하지 않도록 매칭 전에 걸러 내고, 잘못된 IP는 맞지 않는 것으로 봅니다.
*/
class IpRangeMatcher(
ranges: List<String>,
) {
private val matchers = ranges.map { it.trim() }.filter { it.isNotEmpty() }.map(::IpAddressMatcher)

fun matches(ip: String?): Boolean =
ip != null &&
isIpLiteral(ip) &&
runCatching { matchers.any { it.matches(ip) } }.getOrDefault(false)

companion object {
private val IPV4 = Regex("""^\d{1,3}(\.\d{1,3}){3}$""")
private val IPV6 = Regex("""^[0-9a-fA-F:.]+$""")

fun isIpLiteral(value: String): Boolean = isIpv4(value) || isIpv6(value)

private fun isIpv4(value: String): Boolean = IPV4.matches(value) && value.split('.').all { it.toInt() <= 255 }

// ':'가 들어간 값은 InetAddress가 IPv6 리터럴로만 해석하므로 DNS 조회가 일어나지 않습니다.
private fun isIpv6(value: String): Boolean =
value.contains(':') && IPV6.matches(value) && runCatching { InetAddress.getByName(value) }.isSuccess
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.rush.gateway.security

import com.rush.gateway.config.GatewayProperties
import com.rush.gateway.error.ErrorResponseWriter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.authorization.AuthorizationDecision
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.server.ServerAuthenticationEntryPoint
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
import reactor.core.publisher.Mono

/**
* Spring Security는 경로 접근 규칙, CORS, 보안 응답 헤더만 맡습니다.
* JWT 검증은 라우트에 걸리는 JwtAuthenticationFilter가 그대로 합니다.
*/
@Configuration
@EnableWebFluxSecurity
class SecurityConfig(
private val properties: GatewayProperties,
private val clientIpResolver: ClientIpResolver,
private val errorResponseWriter: ErrorResponseWriter,
) {
private val adminAllowedIps = IpRangeMatcher(properties.adminAllowedIps)

@Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain =
http
.csrf { it.disable() }
.httpBasic { it.disable() }
.formLogin { it.disable() }
.logout { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) }
.authorizeExchange {
it
.pathMatchers("/actuator/health", "/actuator/health/**")
.permitAll()
.pathMatchers("/actuator/**")
.access { _, context ->
val ip = clientIpResolver.resolve(context.exchange.request)
Mono.just(AuthorizationDecision(adminAllowedIps.matches(ip)))
}.anyExchange()
.permitAll()
}.exceptionHandling {
it
.authenticationEntryPoint(forbiddenEntryPoint())
.accessDeniedHandler(forbiddenHandler())
}.build()

// 로그인 개념이 없으므로 인증 실패도 접근 거부(403)로 응답합니다.
private fun forbiddenEntryPoint() =
ServerAuthenticationEntryPoint { exchange, _ ->
errorResponseWriter.write(exchange, HttpStatus.FORBIDDEN)
}

private fun forbiddenHandler() =
ServerAccessDeniedHandler { exchange, _ ->
errorResponseWriter.write(exchange, HttpStatus.FORBIDDEN)
}

private fun corsConfigurationSource(): CorsConfigurationSource {
val source = UrlBasedCorsConfigurationSource()
if (properties.corsAllowedOrigins.isEmpty()) return source

val config =
CorsConfiguration().apply {
allowedOrigins = properties.corsAllowedOrigins
allowedMethods =
listOf(HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS)
.map { it.name() }
allowedHeaders = listOf("*")
exposedHeaders = listOf(HttpHeaders.LOCATION, "X-Request-Id")
allowCredentials = true
maxAge = 3600
}
source.registerCorsConfiguration("/**", config)
return source
}
}
10 changes: 9 additions & 1 deletion rush-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ spring:
name: rush-gateway
config:
import: optional:configserver:${CONFIG_URL:http://localhost:8888}
autoconfigure:
# 로그인 사용자가 없으므로 기본 사용자와 임시 비밀번호를 만들지 않습니다.
exclude: org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration
cloud:
loadbalancer:
cache:
Expand Down Expand Up @@ -90,8 +93,13 @@ resilience4j:
default:
timeout-duration: 5s

# health만 공개되고, 나머지는 SecurityConfig가 gateway.admin-allowed-ips에서만 허용합니다.
management:
endpoints:
web:
exposure:
include: health, info, gateway
include: health, info, gateway, circuitbreakers
endpoint:
gateway:
# SCG 기본값은 none(꺼짐)입니다. 라우트 조회만 열고 생성·삭제·refresh는 막습니다.
access: read-only
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.rush.gateway.security

import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.reactive.server.WebTestClient

// @AutoConfigureWebTestClient는 mock 바인딩이라 remoteAddress가 없으므로, 실제 서버(RANDOM_PORT)로 요청합니다.
private const val SECRET = "test-secret-key-for-gateway-actuator-test-0123456789"

@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"eureka.client.enabled=false",
"jwt.secret=$SECRET",
"gateway.admin-allowed-ips=10.0.0.0/8",
],
)
class ActuatorDeniedIpTest {
@Autowired
lateinit var client: WebTestClient

@Test
fun `health는 누구나 볼 수 있다`() {
client
.get()
.uri("/actuator/health")
.exchange()
.expectStatus()
.isOk
}

@Test
fun `gateway 엔드포인트는 403 공통 오류 응답을 준다`() {
client
.get()
.uri("/actuator/gateway/routes")
.exchange()
.expectStatus()
.isForbidden
.expectBody()
.jsonPath("$.code")
.isEqualTo("FORBIDDEN")
.jsonPath("$.message")
.isEqualTo("접근 권한이 없습니다.")
}

@Test
fun `X-Forwarded-For를 위조해도 허용되지 않는다`() {
client
.get()
.uri("/actuator/circuitbreakers")
.header("X-Forwarded-For", "10.0.0.1")
.exchange()
.expectStatus()
.isForbidden
}
}

@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"eureka.client.enabled=false",
"jwt.secret=$SECRET",
],
)
class ActuatorAllowedIpTest {
@Autowired
lateinit var client: WebTestClient

@Test
fun `기본 허용 대역인 localhost는 gateway 엔드포인트를 볼 수 있다`() {
client
.get()
.uri("/actuator/gateway/routes")
.exchange()
.expectStatus()
.isOk
}
}
Loading
Loading