fixing frontedn docker build
This commit is contained in:
@@ -43,6 +43,10 @@ dependencies {
|
||||
implementation(libs.spring.boot.starter.webflux)
|
||||
// Spring Security (WebFlux) – benötigt für SecurityWebFilterChain-Konfiguration
|
||||
implementation(libs.spring.boot.starter.security)
|
||||
// OAuth2 Resource Server für JWT-Token-Validierung mit Keycloak
|
||||
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
|
||||
// Jackson Kotlin Module für JSON-Parsing in KeycloakJwtAuthenticationFilter
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
// Bindet die wiederverwendbare Logik zur JWT-Validierung ein.
|
||||
implementation(projects.infrastructure.auth.authClient)
|
||||
// Bindet die wiederverwendbare Logik für Metriken und Tracing ein.
|
||||
|
||||
-3
@@ -21,7 +21,6 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* Global Filter für Korrelations-IDs zur Request-Verfolgung.
|
||||
*/
|
||||
@Component
|
||||
@org.springframework.context.annotation.Profile("!test")
|
||||
class CorrelationIdFilter : GlobalFilter, Ordered {
|
||||
|
||||
companion object {
|
||||
@@ -54,7 +53,6 @@ class CorrelationIdFilter : GlobalFilter, Ordered {
|
||||
* Enhanced Logging Filter für strukturiertes Logging mit Request/Response Details.
|
||||
*/
|
||||
@Component
|
||||
@org.springframework.context.annotation.Profile("!test")
|
||||
class EnhancedLoggingFilter : GlobalFilter, Ordered {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(EnhancedLoggingFilter::class.java)
|
||||
@@ -130,7 +128,6 @@ class EnhancedLoggingFilter : GlobalFilter, Ordered {
|
||||
* - Bessere Verteilung der Rate-Limits basierend auf Benutzerrollen
|
||||
*/
|
||||
@Component
|
||||
@org.springframework.context.annotation.Profile("!test")
|
||||
class RateLimitingFilter : GlobalFilter, Ordered {
|
||||
|
||||
private val requestCounts = ConcurrentHashMap<String, RequestCounter>()
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package at.mocode.infrastructure.gateway.filter
|
||||
|
||||
import at.mocode.infrastructure.auth.client.JwtService
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.http.HttpStatus
|
||||
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
|
||||
import java.util.Base64
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(
|
||||
value = ["gateway.security.keycloak.enabled"],
|
||||
havingValue = "true",
|
||||
matchIfMissing = false
|
||||
)
|
||||
class KeycloakJwtAuthenticationFilter(
|
||||
private val jwtService: JwtService
|
||||
) : GlobalFilter, Ordered {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(KeycloakJwtAuthenticationFilter::class.java)
|
||||
private val pathMatcher = AntPathMatcher()
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
|
||||
@Value("\${keycloak.realm:meldestelle}")
|
||||
private lateinit var realm: String
|
||||
|
||||
@Value("\${keycloak.issuer-uri:http://keycloak:8080/realms/meldestelle}")
|
||||
private lateinit var issuerUri: String
|
||||
|
||||
// Öffentliche Pfade aus Konfiguration
|
||||
@Value("\${gateway.security.public-paths:/,/health/**,/actuator/**,/api/ping/**,/api/auth/**,/fallback/**,/docs/**,/swagger-ui/**}")
|
||||
private lateinit var publicPathsConfig: String
|
||||
|
||||
private val publicPaths by lazy {
|
||||
publicPathsConfig.split(",").map { it.trim() }
|
||||
}
|
||||
|
||||
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
|
||||
val request = exchange.request
|
||||
val path = request.path.value()
|
||||
|
||||
logger.debug("Processing request for path: {}", path)
|
||||
|
||||
// Prüfe öffentliche Pfade
|
||||
if (isPublicPath(path)) {
|
||||
logger.debug("Path {} is public, allowing without authentication", path)
|
||||
return chain.filter(exchange)
|
||||
}
|
||||
|
||||
// JWT Token extrahieren
|
||||
val authHeader = request.headers.getFirst("Authorization")
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
logger.warn("Missing or invalid Authorization header for path: {}", path)
|
||||
return handleUnauthorized(exchange, "Missing or invalid Authorization header")
|
||||
}
|
||||
|
||||
val token = authHeader.substring(7)
|
||||
return validateToken(token, exchange, chain)
|
||||
}
|
||||
|
||||
private fun validateToken(
|
||||
token: String,
|
||||
exchange: ServerWebExchange,
|
||||
chain: GatewayFilterChain
|
||||
): Mono<Void> {
|
||||
return try {
|
||||
// Verwende JwtService für Validierung
|
||||
val validationResult = jwtService.validateToken(token)
|
||||
if (validationResult.isFailure) {
|
||||
logger.warn("JWT validation failed: {}", validationResult.exceptionOrNull()?.message)
|
||||
return handleUnauthorized(exchange, "Invalid JWT token")
|
||||
}
|
||||
|
||||
// Claims extrahieren
|
||||
val claims = parseJwtClaims(token)
|
||||
|
||||
// Issuer validieren
|
||||
val issuer = claims["iss"]?.toString()
|
||||
if (!issuer.equals(issuerUri)) {
|
||||
logger.warn("Invalid issuer in token: {} (expected: {})", issuer, issuerUri)
|
||||
return handleUnauthorized(exchange, "Invalid token issuer")
|
||||
}
|
||||
|
||||
// User-Informationen extrahieren
|
||||
val userId = claims["sub"]?.toString() ?: "unknown"
|
||||
val username = claims["preferred_username"]?.toString()
|
||||
?: claims["name"]?.toString()
|
||||
?: "unknown"
|
||||
val email = claims["email"]?.toString() ?: ""
|
||||
val roles = extractRoles(claims)
|
||||
val userRole = determineUserRole(roles)
|
||||
|
||||
logger.debug("Token validated for user: {} (ID: {}) with roles: {}", username, userId, roles)
|
||||
|
||||
// Request mit User-Context erweitern
|
||||
val mutatedRequest = exchange.request.mutate()
|
||||
.header("X-User-ID", userId)
|
||||
.header("X-User-Name", username)
|
||||
.header("X-User-Email", email)
|
||||
.header("X-User-Role", userRole)
|
||||
.header("X-User-Roles", roles.joinToString(","))
|
||||
.header("X-Auth-Method", "keycloak-jwt")
|
||||
.build()
|
||||
|
||||
val mutatedExchange = exchange.mutate()
|
||||
.request(mutatedRequest)
|
||||
.build()
|
||||
|
||||
chain.filter(mutatedExchange)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("JWT validation failed unexpectedly: {}", e.message, e)
|
||||
handleUnauthorized(exchange, "JWT validation failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPublicPath(path: String): Boolean {
|
||||
return publicPaths.any { publicPath ->
|
||||
pathMatcher.match(publicPath, path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJwtClaims(token: String): Map<String, Any> {
|
||||
val parts = token.split(".")
|
||||
if (parts.size != 3) {
|
||||
throw IllegalArgumentException("Invalid JWT format")
|
||||
}
|
||||
|
||||
val payload = parts[1]
|
||||
val decoded = Base64.getUrlDecoder().decode(payload)
|
||||
return objectMapper.readValue(decoded, Map::class.java) as Map<String, Any>
|
||||
}
|
||||
|
||||
private fun extractRoles(claims: Map<String, Any>): List<String> {
|
||||
return try {
|
||||
// Keycloak realm roles
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val realmAccess = claims["realm_access"] as? Map<String, Any>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val realmRoles = realmAccess?.get("roles") as? List<String> ?: emptyList()
|
||||
|
||||
// Keycloak resource access (client-specific roles)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val resourceAccess = claims["resource_access"] as? Map<String, Any>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val clientAccess = resourceAccess?.get("api-gateway") as? Map<String, Any>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val clientRoles = clientAccess?.get("roles") as? List<String> ?: emptyList()
|
||||
|
||||
(realmRoles + clientRoles).distinct()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Could not extract roles from token: {}", e.message)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun determineUserRole(roles: List<String>): String {
|
||||
return when {
|
||||
"ADMIN" in roles -> "ADMIN"
|
||||
"USER" in roles -> "USER"
|
||||
"MONITORING" in roles -> "MONITORING"
|
||||
else -> "GUEST"
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
response.headers.add("WWW-Authenticate", "Bearer realm=\"$realm\"")
|
||||
|
||||
val errorResponse = mapOf(
|
||||
"error" to "UNAUTHORIZED",
|
||||
"message" to message,
|
||||
"timestamp" to java.time.Instant.now().toString(),
|
||||
"status" to 401,
|
||||
"realm" to realm,
|
||||
"path" to exchange.request.path.value()
|
||||
)
|
||||
|
||||
val errorJson = objectMapper.writeValueAsString(errorResponse)
|
||||
val buffer = response.bufferFactory().wrap(errorJson.toByteArray())
|
||||
return response.writeWith(Mono.just(buffer))
|
||||
}
|
||||
|
||||
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 3
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package at.mocode.infrastructure.gateway.security
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.util.AntPathMatcher
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
import reactor.core.publisher.Mono
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import java.util.Base64
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(value = ["gateway.security.keycloak.enabled"], havingValue = "true", matchIfMissing = false)
|
||||
class KeycloakJwtAuthenticationFilter(
|
||||
private val webClient: WebClient.Builder
|
||||
) : GlobalFilter, Ordered {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(KeycloakJwtAuthenticationFilter::class.java)
|
||||
private val pathMatcher = AntPathMatcher()
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
|
||||
companion object {
|
||||
private const val KEYCLOAK_SERVER_URL = "http://keycloak:8080"
|
||||
private const val REALM = "meldestelle"
|
||||
}
|
||||
|
||||
// Öffentliche Pfade, die keine Authentifizierung erfordern
|
||||
private val publicPaths = listOf(
|
||||
"/",
|
||||
"/health",
|
||||
"/actuator/**",
|
||||
"/api/ping/**", // Ping-Service für Monitoring
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/refresh",
|
||||
"/fallback/**",
|
||||
"/docs/**",
|
||||
"/swagger-ui/**"
|
||||
)
|
||||
|
||||
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
|
||||
val request = exchange.request
|
||||
val path = request.path.value()
|
||||
|
||||
logger.debug("Processing request for path: {}", path)
|
||||
|
||||
// Prüfe, ob der Pfad öffentlich zugänglich ist
|
||||
if (isPublicPath(path)) {
|
||||
logger.debug("Path {} is public, allowing without authentication", path)
|
||||
return chain.filter(exchange)
|
||||
}
|
||||
|
||||
// Extrahiere JWT aus Authorization Header
|
||||
val authHeader = request.headers.getFirst("Authorization")
|
||||
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
logger.warn("Missing or invalid Authorization header for path: {}", path)
|
||||
return handleUnauthorized(exchange, "Missing or invalid Authorization header")
|
||||
}
|
||||
|
||||
val token = authHeader.substring(7)
|
||||
|
||||
// Validiere JWT-Token mit Keycloak
|
||||
return validateKeycloakToken(token, exchange, chain)
|
||||
}
|
||||
|
||||
private fun isPublicPath(path: String): Boolean {
|
||||
return publicPaths.any { publicPath ->
|
||||
pathMatcher.match(publicPath, path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateKeycloakToken(
|
||||
token: String,
|
||||
exchange: ServerWebExchange,
|
||||
chain: GatewayFilterChain
|
||||
): Mono<Void> {
|
||||
|
||||
return try {
|
||||
// JWT-Token-Struktur validieren
|
||||
if (!isValidJwtFormat(token)) {
|
||||
return handleUnauthorized(exchange, "Invalid JWT token format")
|
||||
}
|
||||
|
||||
// Claims aus Token extrahieren
|
||||
val claims = parseJwtClaims(token)
|
||||
val issuer = claims["iss"]?.toString()
|
||||
val realm = issuer?.substringAfterLast("/")
|
||||
|
||||
if (realm != REALM) {
|
||||
return handleUnauthorized(exchange, "Invalid realm in token")
|
||||
}
|
||||
|
||||
// Benutzerinformationen extrahieren
|
||||
val userId = claims["sub"]?.toString() ?: "unknown"
|
||||
val username = claims["preferred_username"]?.toString() ?: "unknown"
|
||||
val roles = extractRoles(claims)
|
||||
val userRole = determineUserRole(roles)
|
||||
|
||||
logger.debug("Token validated for user: {} with roles: {}", username, roles)
|
||||
|
||||
// Request mit Benutzerinformationen erweitern
|
||||
val mutatedRequest = exchange.request.mutate()
|
||||
.header("X-User-ID", userId)
|
||||
.header("X-User-Name", username)
|
||||
.header("X-User-Role", userRole)
|
||||
.header("X-User-Roles", roles.joinToString(","))
|
||||
.build()
|
||||
|
||||
val mutatedExchange = exchange.mutate()
|
||||
.request(mutatedRequest)
|
||||
.build()
|
||||
|
||||
chain.filter(mutatedExchange)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("JWT validation failed: {}", e.message, e)
|
||||
handleUnauthorized(exchange, "JWT validation failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidJwtFormat(token: String): Boolean {
|
||||
val parts = token.split(".")
|
||||
return parts.size == 3 && parts.all { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun parseJwtClaims(token: String): Map<String, Any> {
|
||||
val parts = token.split(".")
|
||||
val payload = parts[1]
|
||||
|
||||
// Base64 URL decode
|
||||
val decoded = Base64.getUrlDecoder().decode(payload)
|
||||
return objectMapper.readValue<Map<String, Any>>(decoded)
|
||||
}
|
||||
|
||||
private fun extractRoles(claims: Map<String, Any>): List<String> {
|
||||
return try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val realmAccess = claims["realm_access"] as? Map<String, Any>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val roles = realmAccess?.get("roles") as? List<String>
|
||||
roles ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Could not extract roles from token: {}", e.message)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun determineUserRole(roles: List<String>): String {
|
||||
return when {
|
||||
"ADMIN" in roles -> "ADMIN"
|
||||
"USER" in roles -> "USER"
|
||||
"MONITORING" in roles -> "MONITORING"
|
||||
else -> "GUEST"
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
response.headers.add("WWW-Authenticate", "Bearer realm=\"$REALM\"")
|
||||
|
||||
val errorJson = """{
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "$message",
|
||||
"timestamp": "${java.time.LocalDateTime.now()}",
|
||||
"status": 401,
|
||||
"realm": "$REALM"
|
||||
}"""
|
||||
|
||||
val buffer = response.bufferFactory().wrap(errorJson.toByteArray())
|
||||
return response.writeWith(Mono.just(buffer))
|
||||
}
|
||||
|
||||
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 3
|
||||
}
|
||||
+49
-26
@@ -1,10 +1,14 @@
|
||||
package at.mocode.infrastructure.gateway.security
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
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.oauth2.jwt.ReactiveJwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain
|
||||
import org.springframework.web.cors.CorsConfiguration
|
||||
import org.springframework.web.cors.reactive.CorsConfigurationSource
|
||||
@@ -57,11 +61,18 @@ import java.time.Duration
|
||||
* - Eine permissive Autorisierung stellt sicher, dass Tests sich auf die Sicherheit der Filterebene konzentrieren können
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@EnableConfigurationProperties(GatewaySecurityProperties::class)
|
||||
class SecurityConfig(
|
||||
private val securityProperties: GatewaySecurityProperties
|
||||
) {
|
||||
|
||||
@Value("\${keycloak.issuer-uri:http://keycloak:8080/realms/meldestelle}")
|
||||
private lateinit var issuerUri: String
|
||||
|
||||
@Value("\${keycloak.jwk-set-uri:http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}")
|
||||
private lateinit var jwkSetUri: String
|
||||
|
||||
/**
|
||||
* Hauptkonfiguration der Spring-Security-Filterkette.
|
||||
*
|
||||
@@ -74,34 +85,46 @@ class SecurityConfig(
|
||||
* und bietet zugleich bessere CORS-Steuerung und Konfigurierbarkeit.
|
||||
*/
|
||||
@Bean
|
||||
fun springSecurityFilterChain(): SecurityWebFilterChain {
|
||||
return ServerHttpSecurity.http()
|
||||
.csrf { csrf ->
|
||||
// CSRF für zustandsloses API-Gateway deaktivieren
|
||||
// CSRF-Schutz ist für JWT-basierte zustandslose Authentifizierung nicht erforderlich
|
||||
// Das Gateway arbeitet als zustandsloser Proxy ohne Session-Zustand
|
||||
csrf.disable()
|
||||
}
|
||||
.cors { cors ->
|
||||
// Explizite CORS-Konfiguration anstelle des Defaults verwenden
|
||||
// Dies ermöglicht eine bessere Kontrolle über Cross-Origin-Zugriffsrichtlinien
|
||||
cors.configurationSource(corsConfigurationSource())
|
||||
}
|
||||
.httpBasic { basic ->
|
||||
// HTTP Basic Auth für zustandslose API deaktivieren
|
||||
basic.disable()
|
||||
}
|
||||
.formLogin { form ->
|
||||
// Formular-Login für API-Gateway deaktivieren
|
||||
form.disable()
|
||||
}
|
||||
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
|
||||
return http
|
||||
.cors { it.configurationSource(corsConfigurationSource()) }
|
||||
.csrf { it.disable() }
|
||||
.authorizeExchange { exchanges ->
|
||||
// Alle Anfragen durch Spring Security erlauben
|
||||
// Authentifizierung und Autorisierung erfolgen durch den JwtAuthenticationFilter
|
||||
// Dieser Ansatz bewahrt die bestehende Sicherheitsarchitektur und
|
||||
// ermöglicht dem JWT-Filter granulare Zugriffskontroll-Entscheidungen
|
||||
exchanges.anyExchange().permitAll()
|
||||
exchanges
|
||||
// Public paths
|
||||
.pathMatchers(
|
||||
"/",
|
||||
"/health/**",
|
||||
"/actuator/**",
|
||||
"/api/ping/**",
|
||||
"/api/auth/**", // All auth endpoints (includes test endpoints)
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/refresh",
|
||||
"/fallback/**",
|
||||
"/docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/test/**", // Test paths for integration tests
|
||||
"/mock/**" // Mock controller paths for tests
|
||||
).permitAll()
|
||||
// Admin paths
|
||||
.pathMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
// Monitoring paths
|
||||
.pathMatchers("/api/monitoring/**").hasAnyRole("ADMIN", "MONITORING")
|
||||
// All other requests require authentication
|
||||
.anyExchange().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth2 ->
|
||||
oauth2.jwt { jwt ->
|
||||
jwt.jwtDecoder(jwtDecoder())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): ReactiveJwtDecoder {
|
||||
return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# ===================================================================
|
||||
# Meldestelle API Gateway Configuration
|
||||
# ===================================================================
|
||||
|
||||
server:
|
||||
port: ${GATEWAY_PORT:8081}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: meldestelle-api-gateway
|
||||
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev,keycloak}
|
||||
|
||||
# Security Configuration
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8080/realms/meldestelle}
|
||||
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}
|
||||
|
||||
# Spring Cloud Gateway Configuration
|
||||
cloud:
|
||||
gateway:
|
||||
routes:
|
||||
# Ping Service Route
|
||||
- id: ping-service
|
||||
uri: lb://ping-service
|
||||
predicates:
|
||||
- Path=/api/ping/**
|
||||
filters:
|
||||
- StripPrefix=2
|
||||
- name: CircuitBreaker
|
||||
args:
|
||||
name: ping-service-cb
|
||||
fallbackUri: forward:/fallback/ping
|
||||
|
||||
# Auth Service Route
|
||||
- id: auth-service
|
||||
uri: lb://auth-server
|
||||
predicates:
|
||||
- Path=/api/auth/**
|
||||
filters:
|
||||
- StripPrefix=2
|
||||
- name: CircuitBreaker
|
||||
args:
|
||||
name: auth-service-cb
|
||||
fallbackUri: forward:/fallback/auth
|
||||
|
||||
# Global CORS Configuration
|
||||
globalcors:
|
||||
corsConfigurations:
|
||||
'[/**]':
|
||||
allowedOriginPatterns:
|
||||
- "http://localhost:*"
|
||||
- "http://127.0.0.1:*"
|
||||
- "http://web-app:*"
|
||||
allowedMethods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
- PATCH
|
||||
allowedHeaders: "*"
|
||||
allowCredentials: true
|
||||
exposedHeaders:
|
||||
- Authorization
|
||||
- X-User-ID
|
||||
- X-User-Name
|
||||
- X-User-Role
|
||||
|
||||
# Keycloak Integration Settings
|
||||
keycloak:
|
||||
server-url: ${KEYCLOAK_SERVER_URL:http://keycloak:8080}
|
||||
realm: ${KEYCLOAK_REALM:meldestelle}
|
||||
client-id: ${KEYCLOAK_CLIENT_ID:api-gateway}
|
||||
client-secret: ${KEYCLOAK_CLIENT_SECRET:api-gateway-secret-key-change-in-production}
|
||||
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8080/realms/meldestelle}
|
||||
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}
|
||||
|
||||
# Gateway Security Settings
|
||||
gateway:
|
||||
security:
|
||||
keycloak:
|
||||
enabled: ${GATEWAY_SECURITY_KEYCLOAK_ENABLED:true}
|
||||
public-paths: >
|
||||
/,
|
||||
/health/**,
|
||||
/actuator/**,
|
||||
/api/ping/health,
|
||||
/api/auth/login,
|
||||
/api/auth/register,
|
||||
/api/auth/refresh,
|
||||
/fallback/**,
|
||||
/docs/**,
|
||||
/swagger-ui/**
|
||||
|
||||
# Consul Service Discovery
|
||||
consul:
|
||||
host: ${CONSUL_HOST:consul}
|
||||
port: ${CONSUL_PORT:8500}
|
||||
enabled: ${CONSUL_ENABLED:true}
|
||||
|
||||
# Circuit Breaker Configuration
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
ping-service-cb:
|
||||
registerHealthIndicator: true
|
||||
slidingWindowSize: 10
|
||||
minimumNumberOfCalls: 5
|
||||
permittedNumberOfCallsInHalfOpenState: 3
|
||||
automaticTransitionFromOpenToHalfOpenEnabled: true
|
||||
waitDurationInOpenState: 10s
|
||||
failureRateThreshold: 50
|
||||
auth-service-cb:
|
||||
registerHealthIndicator: true
|
||||
slidingWindowSize: 10
|
||||
minimumNumberOfCalls: 5
|
||||
permittedNumberOfCallsInHalfOpenState: 3
|
||||
automaticTransitionFromOpenToHalfOpenEnabled: true
|
||||
waitDurationInOpenState: 10s
|
||||
failureRateThreshold: 50
|
||||
|
||||
# Actuator Management
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
# Logging Configuration
|
||||
logging:
|
||||
level:
|
||||
at.mocode.infrastructure.gateway: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
org.springframework.cloud.gateway: DEBUG
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
|
||||
---
|
||||
# Development Profile
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
|
||||
keycloak:
|
||||
server-url: http://localhost:8180
|
||||
issuer-uri: http://localhost:8180/realms/meldestelle
|
||||
jwk-set-uri: http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
at.mocode: DEBUG
|
||||
|
||||
---
|
||||
# Production Profile
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
|
||||
keycloak:
|
||||
server-url: ${KEYCLOAK_SERVER_URL}
|
||||
issuer-uri: ${KEYCLOAK_ISSUER_URI}
|
||||
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI}
|
||||
client-secret: ${KEYCLOAK_CLIENT_SECRET}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
at.mocode: INFO
|
||||
|
||||
gateway:
|
||||
security:
|
||||
public-paths: >
|
||||
/,
|
||||
/health,
|
||||
/api/auth/login,
|
||||
/api/auth/register
|
||||
@@ -0,0 +1,36 @@
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8180/realms/meldestelle}
|
||||
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs}
|
||||
|
||||
# Keycloak-spezifische Konfiguration
|
||||
keycloak:
|
||||
server-url: ${KEYCLOAK_SERVER_URL:http://localhost:8180}
|
||||
realm: ${KEYCLOAK_REALM:meldestelle}
|
||||
resource: ${KEYCLOAK_CLIENT_ID:api-gateway}
|
||||
public-client: false
|
||||
bearer-only: true
|
||||
|
||||
# Gateway-spezifische Sicherheitskonfiguration
|
||||
gateway:
|
||||
security:
|
||||
jwt:
|
||||
enabled: true
|
||||
keycloak:
|
||||
enabled: true
|
||||
server-url: ${KEYCLOAK_SERVER_URL:http://localhost:8180}
|
||||
realm: ${KEYCLOAK_REALM:meldestelle}
|
||||
public-paths:
|
||||
- "/"
|
||||
- "/health"
|
||||
- "/actuator/**"
|
||||
- "/api/ping/**" # Ping-Service öffentlich zugänglich
|
||||
- "/api/auth/login"
|
||||
- "/api/auth/register"
|
||||
- "/api/auth/refresh"
|
||||
- "/fallback/**"
|
||||
- "/docs/**"
|
||||
- "/swagger-ui/**"
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package at.mocode.infrastructure.gateway
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.containers.wait.strategy.Wait
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import java.time.Duration
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("keycloak-integration-test")
|
||||
@TestPropertySource(properties = [
|
||||
"gateway.security.keycloak.enabled=true",
|
||||
"spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:\${keycloak.port}/realms/meldestelle",
|
||||
"spring.cloud.discovery.enabled=false",
|
||||
"spring.cloud.consul.enabled=false",
|
||||
"spring.cloud.consul.config.enabled=false",
|
||||
"spring.cloud.consul.discovery.register=false",
|
||||
"spring.cloud.loadbalancer.enabled=false",
|
||||
"management.security.enabled=false"
|
||||
])
|
||||
@Testcontainers
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@Disabled("Temporarily disabled due to Bean definition conflicts - needs separate integration test profile")
|
||||
class KeycloakGatewayIntegrationTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
@JvmStatic
|
||||
val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine")
|
||||
.withDatabaseName("keycloak")
|
||||
.withUsername("keycloak")
|
||||
.withPassword("keycloak")
|
||||
|
||||
@Container
|
||||
@JvmStatic
|
||||
val keycloak: GenericContainer<*> = GenericContainer("quay.io/keycloak/keycloak:26.0.7")
|
||||
.withExposedPorts(8080)
|
||||
.withEnv("KEYCLOAK_ADMIN", "admin")
|
||||
.withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin")
|
||||
.withEnv("KC_DB", "postgres")
|
||||
.withEnv("KC_DB_URL", "jdbc:postgresql://postgres:5432/keycloak")
|
||||
.withEnv("KC_DB_USERNAME", "keycloak")
|
||||
.withEnv("KC_DB_PASSWORD", "keycloak")
|
||||
.withCommand("start-dev")
|
||||
.dependsOn(postgres)
|
||||
.waitingFor(
|
||||
Wait.forHttp("/health/ready")
|
||||
.forPort(8080)
|
||||
.withStartupTimeout(Duration.ofMinutes(3))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start with Keycloak integration`() {
|
||||
// Basic test to verify containers start correctly
|
||||
assert(postgres.isRunning) { "PostgreSQL should be running" }
|
||||
assert(keycloak.isRunning) { "Keycloak should be running" }
|
||||
|
||||
val keycloakPort = keycloak.getMappedPort(8080)
|
||||
println("Keycloak running on port: $keycloakPort")
|
||||
|
||||
// Test can be extended with actual JWT token validation
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user