fixing Keycloak Auth

This commit is contained in:
2025-10-02 00:52:24 +02:00
parent 72036207b0
commit 3e3af214e6
21 changed files with 1155 additions and 438 deletions
@@ -1,196 +0,0 @@
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
}
@@ -1,184 +0,0 @@
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
}
@@ -1,6 +1,7 @@
package at.mocode.infrastructure.gateway.security
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
@@ -64,15 +65,11 @@ import java.time.Duration
@EnableWebFluxSecurity
@EnableConfigurationProperties(GatewaySecurityProperties::class)
class SecurityConfig(
private val securityProperties: GatewaySecurityProperties
private val securityProperties: GatewaySecurityProperties,
@Value("\${keycloak.issuer-uri:}") private val issuerUri: String,
@Value("\${keycloak.jwk-set-uri:}") private val jwkSetUri: String
) {
@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.
*
@@ -86,7 +83,7 @@ class SecurityConfig(
*/
@Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
return http
val httpSecurity = http
.cors { it.configurationSource(corsConfigurationSource()) }
.csrf { it.disable() }
.authorizeExchange { exchanges ->
@@ -107,22 +104,38 @@ class SecurityConfig(
"/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()
.apply {
// Only enforce role-based authorization when oauth2ResourceServer is active
// In tests without oauth2, JwtAuthenticationFilter handles auth via GlobalFilter
if (jwkSetUri.isNotBlank()) {
// Admin paths
pathMatchers("/api/admin/**").hasRole("ADMIN")
// Monitoring paths
pathMatchers("/api/monitoring/**").hasAnyRole("ADMIN", "MONITORING")
// All other requests require authentication
anyExchange().authenticated()
} else {
// Permissive mode for tests - JwtAuthenticationFilter handles auth
anyExchange().permitAll()
}
}
}
.oauth2ResourceServer { oauth2 ->
// Only configure oauth2ResourceServer if Keycloak JWK URI is configured
// In tests, this will be empty, allowing JwtAuthenticationFilter to handle auth
if (jwkSetUri.isNotBlank()) {
httpSecurity.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt ->
jwt.jwtDecoder(jwtDecoder())
}
}
.build()
}
return httpSecurity.build()
}
@Bean
@ConditionalOnProperty(name = ["keycloak.jwk-set-uri"])
fun jwtDecoder(): ReactiveJwtDecoder {
return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri)
.build()
@@ -1,16 +1,29 @@
# ===================================================================
# Keycloak Profile Configuration
# ===================================================================
# This profile configures OAuth2/JWT authentication with Keycloak.
# Uses Spring Security's oauth2ResourceServer for secure JWT validation.
# ===================================================================
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}
# Issuer URI for JWT validation - Docker internal: keycloak:8080, External: localhost:8180
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8080/realms/meldestelle}
# JWK Set URI for fetching public keys to validate JWT signatures
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}
# Keycloak-spezifische Konfiguration
keycloak:
server-url: ${KEYCLOAK_SERVER_URL:http://localhost:8180}
# Internal Docker service name, external via port 8180
server-url: ${KEYCLOAK_SERVER_URL:http://keycloak:8080}
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}
realm: ${KEYCLOAK_REALM:meldestelle}
resource: ${KEYCLOAK_CLIENT_ID:api-gateway}
client-id: ${KEYCLOAK_CLIENT_ID:api-gateway}
public-client: false
bearer-only: true
@@ -18,19 +31,11 @@ keycloak:
gateway:
security:
jwt:
# Enable JWT validation via Spring Security OAuth2 Resource Server
enabled: true
keycloak:
enabled: true
server-url: ${KEYCLOAK_SERVER_URL:http://localhost:8180}
# Custom JWT filter DISABLED - using Spring Security oauth2ResourceServer instead
# This prevents duplicate authentication and ensures proper JWT signature validation
enabled: false
server-url: ${KEYCLOAK_SERVER_URL:http://keycloak:8080}
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/**"
@@ -6,6 +6,11 @@ spring:
name: api-gateway-test
main:
web-application-type: reactive
autoconfigure:
exclude:
# Disable OAuth2 ResourceServer auto-configuration in tests
# Tests use mock JwtAuthenticationFilter instead of real JWT validation
- org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration
cloud:
discovery:
enabled: false
@@ -63,8 +68,3 @@ logging:
level:
org.springframework.cloud.gateway: WARN
at.mocode.infrastructure.gateway: DEBUG
gateway:
security:
jwt:
enabled: false