fixing(Gateway)

This commit is contained in:
stefan
2025-08-13 14:18:59 +02:00
parent 93633b38a7
commit 562eb07be1
21 changed files with 2081 additions and 94 deletions
@@ -0,0 +1,197 @@
package at.mocode.infrastructure.gateway.config
import org.springframework.cloud.gateway.filter.GatewayFilter
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.http.HttpStatus
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.http.server.reactive.ServerHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* Gateway-Konfiguration für erweiterte Funktionalitäten wie Logging, Rate Limiting und Security.
*/
/**
* Global Filter für Korrelations-IDs zur Request-Verfolgung.
*/
@Component
@org.springframework.context.annotation.Profile("!test")
class CorrelationIdFilter : GlobalFilter, Ordered {
companion object {
const val CORRELATION_ID_HEADER = "X-Correlation-ID"
}
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val request = exchange.request
val correlationId = request.headers.getFirst(CORRELATION_ID_HEADER)
?: UUID.randomUUID().toString()
val mutatedRequest = request.mutate()
.header(CORRELATION_ID_HEADER, correlationId)
.build()
val mutatedExchange = exchange.mutate()
.request(mutatedRequest)
.build()
// Add a response header after processing
mutatedExchange.response.headers.add(CORRELATION_ID_HEADER, correlationId)
return chain.filter(mutatedExchange)
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
}
/**
* Enhanced Logging Filter für strukturiertes Logging mit Request/Response Details.
*/
@Component
@org.springframework.context.annotation.Profile("!test")
class EnhancedLoggingFilter : GlobalFilter, Ordered {
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val startTime = System.currentTimeMillis()
val request = exchange.request
val correlationId = request.headers.getFirst(CorrelationIdFilter.CORRELATION_ID_HEADER)
logRequest(request, correlationId)
return chain.filter(exchange)
.doOnSuccess {
val responseTime = System.currentTimeMillis() - startTime
logResponse(exchange.response, correlationId, responseTime)
}
.doOnError { error ->
val responseTime = System.currentTimeMillis() - startTime
logError(error, correlationId, responseTime)
}
}
private fun logRequest(request: ServerHttpRequest, correlationId: String?) {
println("""
[${LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)}] [REQUEST] [${correlationId}]
Method: ${request.method}
URI: ${request.uri}
RemoteAddress: ${request.remoteAddress}
UserAgent: ${request.headers.getFirst("User-Agent")}
""".trimIndent())
}
private fun logResponse(response: ServerHttpResponse, correlationId: String?, responseTime: Long) {
println("""
[${LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)}] [RESPONSE] [${correlationId}]
Status: ${response.statusCode}
ResponseTime: ${responseTime}ms
""".trimIndent())
}
private fun logError(error: Throwable, correlationId: String?, responseTime: Long) {
println("""
[${LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)}] [ERROR] [${correlationId}]
Error: ${error.message}
ResponseTime: ${responseTime}ms
""".trimIndent())
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 1
}
/**
* Rate Limiting Filter basierend auf IP-Adresse und User-Typ.
*/
@Component
@org.springframework.context.annotation.Profile("!test")
class RateLimitingFilter : GlobalFilter, Ordered {
private val requestCounts = ConcurrentHashMap<String, RequestCounter>()
companion object {
const val RATE_LIMIT_ENABLED_HEADER = "X-RateLimit-Enabled"
const val RATE_LIMIT_LIMIT_HEADER = "X-RateLimit-Limit"
const val RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining"
// Rate Limits pro Minute
const val ANONYMOUS_LIMIT = 50
const val AUTHENTICATED_LIMIT = 200
const val ADMIN_LIMIT = 500
const val AUTH_ENDPOINT_LIMIT = 20
const val DEFAULT_LIMIT = 100
}
data class RequestCounter(
var count: Int = 0,
var lastReset: Long = System.currentTimeMillis()
)
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val request = exchange.request
val response = exchange.response
val clientIp = getClientIp(request)
val path = request.path.value()
val limit = determineRateLimit(request, path)
val counter = requestCounts.computeIfAbsent(clientIp) { RequestCounter() }
// Reset counter if more than a minute has passed
val now = System.currentTimeMillis()
if (now - counter.lastReset > 60_000) {
counter.count = 0
counter.lastReset = now
}
counter.count++
// Add rate limit headers
response.headers.add(RATE_LIMIT_ENABLED_HEADER, "true")
response.headers.add(RATE_LIMIT_LIMIT_HEADER, limit.toString())
response.headers.add(RATE_LIMIT_REMAINING_HEADER, maxOf(0, limit - counter.count).toString())
return if (counter.count > limit) {
response.statusCode = HttpStatus.TOO_MANY_REQUESTS
response.setComplete()
} else {
chain.filter(exchange)
}
}
private fun getClientIp(request: ServerHttpRequest): String {
return request.headers.getFirst("X-Forwarded-For")?.split(",")?.first()?.trim()
?: request.headers.getFirst("X-Real-IP")
?: request.remoteAddress?.address?.hostAddress
?: "unknown"
}
private fun determineRateLimit(request: ServerHttpRequest, path: String): Int {
return when {
path.startsWith("/api/auth") -> AUTH_ENDPOINT_LIMIT
isAdminUser(request) -> ADMIN_LIMIT
isAuthenticatedUser(request) -> AUTHENTICATED_LIMIT
else -> ANONYMOUS_LIMIT
}
}
private fun isAuthenticatedUser(request: ServerHttpRequest): Boolean {
return request.headers.getFirst("Authorization") != null
}
private fun isAdminUser(request: ServerHttpRequest): Boolean {
// This would typically decode the JWT and check for admin role
// For now, we'll use a simple header check
return request.headers.getFirst("X-User-Role") == "ADMIN"
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 2
}
@@ -0,0 +1,71 @@
package at.mocode.infrastructure.gateway.controller
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
/**
* Fallback Controller für Circuit Breaker Szenarien.
* Bietet standardisierte Fehlermeldungen wenn Backend-Services nicht verfügbar sind.
*/
@RestController
@RequestMapping("/fallback")
class FallbackController {
@RequestMapping(value = ["/members"], method = [RequestMethod.GET, RequestMethod.POST])
fun membersFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("members-service", "Member operations are temporarily unavailable")
}
@RequestMapping(value = ["/horses"], method = [RequestMethod.GET, RequestMethod.POST])
fun horsesFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("horses-service", "Horse registry operations are temporarily unavailable")
}
@RequestMapping(value = ["/events"], method = [RequestMethod.GET, RequestMethod.POST])
fun eventsFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("events-service", "Event management operations are temporarily unavailable")
}
@RequestMapping(value = ["/masterdata"], method = [RequestMethod.GET, RequestMethod.POST])
fun masterdataFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("masterdata-service", "Master data operations are temporarily unavailable")
}
@RequestMapping(value = ["/auth"], method = [RequestMethod.GET, RequestMethod.POST])
fun authFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("auth-service", "Authentication operations are temporarily unavailable")
}
@RequestMapping(value = [""], method = [RequestMethod.GET, RequestMethod.POST])
fun defaultFallback(): ResponseEntity<ErrorResponse> {
return createFallbackResponse("unknown-service", "Service is temporarily unavailable")
}
private fun createFallbackResponse(service: String, message: String): ResponseEntity<ErrorResponse> {
val errorResponse = ErrorResponse(
error = "SERVICE_UNAVAILABLE",
message = message,
service = service,
timestamp = LocalDateTime.now(),
status = HttpStatus.SERVICE_UNAVAILABLE.value(),
suggestion = "Please try again in a few moments. If the problem persists, contact support."
)
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse)
}
}
/**
* Standardisierte Fehlerantwort für Circuit Breaker Fallbacks.
*/
data class ErrorResponse(
val error: String,
val message: String,
val service: String,
val timestamp: LocalDateTime,
val status: Int,
val suggestion: String
)
@@ -0,0 +1,128 @@
package at.mocode.infrastructure.gateway.security
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.cloud.gateway.filter.GatewayFilter
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
import org.springframework.context.annotation.Profile
import org.springframework.core.Ordered
import org.springframework.http.HttpStatus
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.http.server.reactive.ServerHttpResponse
import org.springframework.stereotype.Component
import org.springframework.util.AntPathMatcher
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
/**
* JWT Authentication Filter für das Gateway.
* Validiert JWT-Tokens für alle geschützten Endpunkte.
*/
@Component
@ConditionalOnProperty(value = ["gateway.security.jwt.enabled"], havingValue = "true", matchIfMissing = true)
class JwtAuthenticationFilter : GlobalFilter, Ordered {
private val pathMatcher = AntPathMatcher()
// Öffentliche Pfade, die keine Authentifizierung erfordern
private val publicPaths = listOf(
"/",
"/health",
"/actuator/**",
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh",
"/fallback/**",
"/docs/**",
"/swagger-ui/**",
"/api/ping/**" // Ping Service für Monitoring
)
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val request = exchange.request
val path = request.path.value()
// Prüfe ob der Pfad öffentlich zugänglich ist
if (isPublicPath(path)) {
return chain.filter(exchange)
}
// Extrahiere JWT aus Authorization Header
val authHeader = request.headers.getFirst("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return handleUnauthorized(exchange, "Missing or invalid Authorization header")
}
val token = authHeader.substring(7)
// Hier würde normalerweise die JWT-Validierung mit dem auth-client erfolgen
// Für diese Implementation verwenden wir eine vereinfachte Validierung
return validateJwtToken(token, exchange, chain)
}
private fun isPublicPath(path: String): Boolean {
return publicPaths.any { publicPath ->
pathMatcher.match(publicPath, path)
}
}
private fun validateJwtToken(
token: String,
exchange: ServerWebExchange,
chain: GatewayFilterChain
): Mono<Void> {
// Einfache Token-Validierung (in der Realität würde hier der auth-client verwendet)
if (token.isEmpty() || token.length < 10) {
return handleUnauthorized(exchange, "Invalid JWT token")
}
// Füge User-Information zu Headers hinzu (simuliert)
val userRole = extractUserRole(token)
val userId = extractUserId(token)
val mutatedRequest = exchange.request.mutate()
.header("X-User-ID", userId)
.header("X-User-Role", userRole)
.build()
val mutatedExchange = exchange.mutate()
.request(mutatedRequest)
.build()
return chain.filter(mutatedExchange)
}
private fun extractUserRole(token: String): String {
// Vereinfachte Rollenextraktion (normalerweise aus JWT Claims)
return when {
token.contains("admin") -> "ADMIN"
token.contains("user") -> "USER"
else -> "GUEST"
}
}
private fun extractUserId(token: String): String {
// Vereinfachte User-ID Extraktion (normalerweise aus JWT Subject)
return "user-${token.hashCode()}"
}
private fun handleUnauthorized(exchange: ServerWebExchange, message: String): Mono<Void> {
val response: ServerHttpResponse = exchange.response
response.statusCode = HttpStatus.UNAUTHORIZED
response.headers.add("Content-Type", "application/json")
val errorJson = """{
"error": "UNAUTHORIZED",
"message": "$message",
"timestamp": "${java.time.LocalDateTime.now()}",
"status": 401
}"""
val buffer = response.bufferFactory().wrap(errorJson.toByteArray())
return response.writeWith(Mono.just(buffer))
}
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 3
}
@@ -0,0 +1,32 @@
package at.mocode.infrastructure.gateway.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.web.server.SecurityWebFilterChain
/**
* Minimal reactive security configuration for the Gateway.
*
* Rationale:
* - During tests, Spring Security is on the classpath (testImplementation), which enables
* security auto-configuration and can lock down all endpoints unless a SecurityWebFilterChain is provided.
* - The Gateway enforces auth using a GlobalFilter (JwtAuthenticationFilter) when enabled via property,
* so the SecurityWebFilterChain should stay permissive and let the filter do the auth work.
*/
@Configuration
class SecurityConfig {
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
return http
.csrf { it.disable() }
.cors { }
.authorizeExchange { exchanges ->
exchanges
.anyExchange().permitAll()
}
.build()
}
}
@@ -1,40 +1,147 @@
# Port, auf dem das Gateway läuft
server:
port: 8080
# Optimierte Netty-Konfiguration für reaktive Anwendungen
netty:
connection-timeout: 5s
idle-timeout: 15s
# Name, unter dem sich das Gateway in Consul registriert
spring:
application:
name: api-gateway
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
security:
user:
name: admin
password: admin
name: ${GATEWAY_ADMIN_USER:admin}
password: ${GATEWAY_ADMIN_PASSWORD:admin}
cloud:
consul:
host: localhost
port: 8500
host: ${CONSUL_HOST:localhost}
port: ${CONSUL_PORT:8500}
discovery:
register: true
health-check-path: /actuator/health
health-check-interval: 10s
instance-id: ${spring.application.name}-${server.port}-${random.uuid}
gateway:
# HTTP Client-Timeouts für stabile Upstream-Verbindungen
httpclient:
connect-timeout: 5000 # in Millisekunden
response-timeout: 30s
# Globales CORS-Setup (kann pro Umgebung überschrieben werden)
pool:
type: elastic
max-idle-time: 15s
max-life-time: 60s
# Verbesserte CORS-Konfiguration
globalcors:
corsConfigurations:
'[/**]':
allowedOrigins: "*"
allowedMethods: "*"
allowedHeaders: "*"
# Antwort-Header bereinigen (verhindert doppelte CORS-Header)
allowedOriginPatterns:
- "https://*.meldestelle.at"
- "http://localhost:*"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- PATCH
- OPTIONS
allowedHeaders:
- "*"
allowCredentials: true
maxAge: 3600
# Antwort-Header bereinigen und globale Filter
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
- name: CircuitBreaker
args:
name: defaultCircuitBreaker
fallbackUri: forward:/fallback
- name: Retry
args:
retries: 3
statuses: BAD_GATEWAY,GATEWAY_TIMEOUT
methods: GET,POST,PUT,DELETE
backoff:
firstBackoff: 50ms
maxBackoff: 500ms
factor: 2
basedOnPreviousValue: false
# Route definitions with service discovery
routes:
# Health Check und Gateway Info Routes
- id: gateway-info-route
uri: http://localhost:${server.port}
predicates:
- Path=/
- Method=GET
filters:
- SetStatus=200
- SetResponseHeader=Content-Type,application/json
# Members Service Routes
- id: members-service-route
uri: lb://members-service
predicates:
- Path=/api/members/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: membersCircuitBreaker
fallbackUri: forward:/fallback/members
# Horses Service Routes
- id: horses-service-route
uri: lb://horses-service
predicates:
- Path=/api/horses/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: horsesCircuitBreaker
fallbackUri: forward:/fallback/horses
# Events Service Routes
- id: events-service-route
uri: lb://events-service
predicates:
- Path=/api/events/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: eventsCircuitBreaker
fallbackUri: forward:/fallback/events
# Masterdata Service Routes
- id: masterdata-service-route
uri: lb://masterdata-service
predicates:
- Path=/api/masterdata/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: masterdataCircuitBreaker
fallbackUri: forward:/fallback/masterdata
# Auth Service Routes (if exists)
- id: auth-service-route
uri: lb://auth-service
predicates:
- Path=/api/auth/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: authCircuitBreaker
fallbackUri: forward:/fallback/auth
# Ping Service Routes (existing)
- id: ping-service-route
uri: lb://ping-service
predicates:
@@ -42,8 +149,71 @@ spring:
filters:
- StripPrefix=1
# Circuit Breaker Configuration
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowSize: 100
minimumNumberOfCalls: 20
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 5s
failureRateThreshold: 50
eventConsumerBufferSize: 10
recordExceptions:
- org.springframework.web.client.HttpServerErrorException
- java.util.concurrent.TimeoutException
- java.io.IOException
instances:
defaultCircuitBreaker:
baseConfig: default
membersCircuitBreaker:
baseConfig: default
slidingWindowSize: 50
horsesCircuitBreaker:
baseConfig: default
slidingWindowSize: 50
eventsCircuitBreaker:
baseConfig: default
slidingWindowSize: 75
masterdataCircuitBreaker:
baseConfig: default
slidingWindowSize: 30
authCircuitBreaker:
baseConfig: default
slidingWindowSize: 20
failureRateThreshold: 30
# Management und Monitoring
management:
endpoints:
web:
exposure:
include: health,info
include: health,info,metrics,prometheus,gateway
endpoint:
health:
show-details: always
show-components: always
metrics:
enabled: true
metrics:
export:
prometheus:
distribution:
percentiles-histogram:
spring.cloud.gateway.requests: true
percentiles:
spring.cloud.gateway.requests: 0.5,0.95,0.99
tags:
application: ${spring.application.name}
# Logging Configuration
logging:
level:
org.springframework.cloud.gateway: INFO
org.springframework.cloud.loadbalancer: DEBUG
at.mocode.infrastructure.gateway: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%X{correlationId:-}] %logger{36} - %msg%n"