refactoring Gateway

This commit is contained in:
2025-10-11 13:18:05 +02:00
parent 4cb35f94a3
commit ebd3171d93
14 changed files with 571 additions and 332 deletions
@@ -6,11 +6,12 @@ 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.config.web.server.authenticated
import org.springframework.security.config.web.server.invoke
import org.springframework.security.config.web.server.pathMatchers
import org.springframework.security.config.web.server.permitAll
import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
@@ -41,10 +42,12 @@ class SecurityConfig(
// 3. Routen-Berechtigungen definieren
authorizeExchange {
// Öffentlich zugängliche Pfade aus der .yml-Datei laden
pathMatchers(*securityProperties.publicPaths.toTypedArray()).permitAll()
authorize(
pathMatchers(*securityProperties.publicPaths.toTypedArray()),
permitAll
)
// Alle anderen Pfade erfordern eine Authentifizierung
anyExchange.authenticated()
authorize(anyExchange, authenticated)
}
// 4. JWT-Validierung via Keycloak aktivieren
@@ -1,8 +1,10 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.reactive.server.WebTestClient
@@ -37,6 +39,7 @@ import org.springframework.test.web.reactive.server.WebTestClient
]
)
@ActiveProfiles("test")
@Import(TestSecurityConfig::class)
class FallbackControllerTests {
@Autowired
@@ -1,7 +1,9 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
/**
@@ -34,6 +36,7 @@ import org.springframework.test.context.ActiveProfiles
]
)
@ActiveProfiles("test")
@Import(TestSecurityConfig::class)
class GatewayApplicationTests {
@Test
@@ -1,5 +1,6 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
@@ -46,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController
)
@ActiveProfiles("dev") // Use dev profile to enable filters
@AutoConfigureWebTestClient
@Import(GatewayFiltersTests.TestFilterConfig::class)
@Import(TestSecurityConfig::class, GatewayFiltersTests.TestFilterConfig::class)
class GatewayFiltersTests {
@Autowired
@@ -1,5 +1,6 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
@@ -9,7 +10,6 @@ import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.web.bind.annotation.GetMapping
@@ -48,7 +48,7 @@ import org.springframework.web.bind.annotation.RestController
)
@ActiveProfiles("test")
@AutoConfigureWebTestClient
@Import(GatewayRoutingTests.TestRoutesConfig::class)
@Import(TestSecurityConfig::class, GatewayRoutingTests.TestRoutesConfig::class)
class GatewayRoutingTests {
@Autowired
@@ -1,5 +1,6 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
@@ -46,7 +47,7 @@ import org.springframework.web.bind.annotation.*
)
@ActiveProfiles("test") // Use test profile to disable unrelated global filters; CORS config is present in application-test.yml
@AutoConfigureWebTestClient
@Import(GatewaySecurityTests.TestSecurityConfig::class)
@Import(TestSecurityConfig::class, GatewaySecurityTests.TestSecurityConfig::class)
class GatewaySecurityTests {
@Autowired
@@ -1,290 +0,0 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.auth.client.JwtService
import at.mocode.infrastructure.auth.client.model.BerechtigungE
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.gateway.route.RouteLocator
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.web.bind.annotation.*
/**
* Tests for JWT Authentication Filter functionality.
* Tests public path exemptions, token validation, and user context injection.
*/
@SpringBootTest(
classes = [GatewayApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
// Disable external dependencies
"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",
// Disable circuit breaker for JWT tests
"resilience4j.circuitbreaker.configs.default.registerHealthIndicator=false",
"management.health.circuitbreakers.enabled=false",
// Disable Redis health indicator for tests (no Redis in the test environment)
"management.health.redis.enabled=false",
// Enable JWT authentication for testing
"gateway.security.jwt.enabled=true",
// Use reactive web application type
"spring.main.web-application-type=reactive",
// Disable gateway discovery - use explicit routes
"spring.cloud.gateway.server.webflux.discovery.locator.enabled=false",
// Disable actuator security
"management.security.enabled=false",
// Set random port
"server.port=0"
]
)
@ActiveProfiles("test") // Use test profile to disable unrelated global filters; JWT is enabled via properties above
@AutoConfigureWebTestClient
@Import(JwtAuthenticationTests.TestJwtConfig::class)
class JwtAuthenticationTests {
@Autowired
lateinit var webTestClient: WebTestClient
@Autowired
lateinit var jwtService: JwtService
@Test
fun `should allow access to public paths without authentication`() {
listOf("/", "/health", "/actuator/health", "/api/auth/login", "/api/ping/health", "/fallback/test").forEach { path ->
webTestClient.get()
.uri(path)
.exchange()
.expectStatus().isOk
}
}
@Test
fun `should return 401 for protected paths without authorization header`() {
webTestClient.get()
.uri("/api/members/protected")
.exchange()
.expectStatus().isUnauthorized
.expectHeader().valueEquals("Content-Type", "application/json")
.expectBody()
.jsonPath("$.error").isEqualTo("UNAUTHORIZED")
.jsonPath("$.message").isEqualTo("Missing or invalid Authorization header")
.jsonPath("$.status").isEqualTo(401)
}
@Test
fun `should return 401 for protected paths with invalid authorization header`() {
webTestClient.get()
.uri("/api/members/protected")
.header("Authorization", "InvalidHeader")
.exchange()
.expectStatus().isUnauthorized
.expectBody()
.jsonPath("$.error").isEqualTo("UNAUTHORIZED")
}
@Test
fun `should return 401 for protected paths with invalid JWT token`() {
webTestClient.get()
.uri("/api/members/protected")
.header("Authorization", "Bearer invalid")
.exchange()
.expectStatus().isUnauthorized
.expectBody()
.jsonPath("$.error").isEqualTo("UNAUTHORIZED")
.jsonPath("$.message").exists() // Auth-client provides detailed error messages
}
@Test
fun `should allow access with valid JWT token and inject user headers`() {
// Generate a real JWT token using the JwtService with USER permissions
val validToken = jwtService.generateToken(
userId = "user-123",
username = "testuser",
permissions = listOf(BerechtigungE.PERSON_READ)
)
webTestClient.get()
.uri("/api/members/protected")
.header("Authorization", "Bearer $validToken")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.consumeWith { result ->
// The mock controller returns injected header values in the message
val body = result.responseBody ?: ""
assert(body.contains("User ID:"))
assert(body.contains("Role:"))
}
}
@Test
fun `should extract admin role from JWT token`() {
// Generate a real JWT token using the JwtService with admin-level permissions
// Using DELETE permissions which map to an ADMIN role according to determineRoleFromPermissions logic
val adminToken = jwtService.generateToken(
userId = "admin-user-123",
username = "adminuser",
permissions = listOf(BerechtigungE.PERSON_DELETE, BerechtigungE.VEREIN_DELETE)
)
webTestClient.get()
.uri("/api/members/protected")
.header("Authorization", "Bearer $adminToken")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.consumeWith { result ->
val body = result.responseBody
assert(body?.contains("ADMIN") == true)
}
}
@Test
fun `should extract user role from JWT token`() {
// Generate a real JWT token using the JwtService with user-level permissions
val userToken = jwtService.generateToken(
userId = "user-456",
username = "regularuser",
permissions = listOf(BerechtigungE.PERSON_READ, BerechtigungE.PFERD_READ)
)
webTestClient.get()
.uri("/api/members/protected")
.header("Authorization", "Bearer $userToken")
.exchange()
.expectStatus().isOk
.expectBody(String::class.java)
.consumeWith { result ->
val body = result.responseBody
assert(body?.contains("USER") == true)
}
}
@Test
fun `should handle POST requests to protected endpoints`() {
// Generate a real JWT token using the JwtService for POST request test
val validToken = jwtService.generateToken(
userId = "user-789",
username = "postuser",
permissions = listOf(BerechtigungE.PERSON_CREATE, BerechtigungE.VEREIN_READ)
)
webTestClient.post()
.uri("/api/members/protected")
.header("Authorization", "Bearer $validToken")
.exchange()
.expectStatus().isOk
}
@Test
fun `should allow access to swagger documentation paths`() {
webTestClient.get()
.uri("/docs/api-docs")
.exchange()
.expectStatus().isOk
}
/**
* Test configuration that provides routes for JWT authentication testing.
*/
@Configuration
class TestJwtConfig {
@Bean
fun jwtTestRoutes(builder: RouteLocatorBuilder): RouteLocator = builder.routes()
.route("test-protected") { r ->
r.path("/api/members/**")
.filters { f -> f.setPath("/mock/protected") }
.uri("forward:/")
}
.route("test-public-health") { r ->
r.path("/health")
.uri("forward:/mock/health")
}
.route("test-public-ping") { r ->
r.path("/api/ping/**")
.filters { f -> f.setPath("/mock/ping") }
.uri("forward:/")
}
.route("test-public-auth") { r ->
r.path("/api/auth/**")
.filters { f -> f.setPath("/mock/auth") }
.uri("forward:/")
}
.route("test-public-fallback") { r ->
r.path("/fallback/**")
.uri("forward:/mock/fallback")
}
.route("test-public-docs") { r ->
r.path("/docs/**")
.uri("forward:/mock/docs")
}
.route("test-public-actuator") { r ->
r.path("/actuator/**")
.uri("forward:/mock/actuator")
}
.route("test-root") { r ->
r.path("/")
.filters { f -> f.setPath("/mock/root") }
.uri("forward:/")
}
.build()
@Bean
fun jwtTestController(): JwtTestController = JwtTestController()
}
/**
* Mock controller for JWT authentication testing.
* Returns information about injected user headers.
*/
@RestController
@RequestMapping("/mock")
class JwtTestController {
@RequestMapping(
value = ["/protected"],
method = [RequestMethod.GET, RequestMethod.POST]
)
fun protectedEndpoint(
@RequestHeader(value = "X-User-ID", required = false) userId: String?,
@RequestHeader(value = "X-User-Role", required = false) userRole: String?
): String {
return "Protected endpoint accessed - User ID: $userId, Role: $userRole"
}
@GetMapping("/health", "/health/**")
fun healthEndpoint(): String = "Health OK"
@GetMapping("/ping", "/ping/**")
fun pingEndpoint(): String = "Ping OK"
@GetMapping("/auth", "/auth/**")
@PostMapping("/auth", "/auth/**")
fun authEndpoint(): String = "Auth endpoint"
@GetMapping("/fallback", "/fallback/**")
fun fallbackEndpoint(): String = "Fallback OK"
@GetMapping("/docs", "/docs/**")
fun docsEndpoint(): String = "Documentation OK"
@GetMapping("/actuator", "/actuator/**")
fun actuatorEndpoint(): String = "Actuator OK"
@GetMapping("/root")
fun rootEndpoint(): Map<String, String> = mapOf(
"service" to "api-gateway",
"status" to "running"
)
}
}
@@ -1,7 +1,9 @@
package at.mocode.infrastructure.gateway
import at.mocode.infrastructure.gateway.config.TestSecurityConfig
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestPropertySource
@@ -24,6 +26,7 @@ import org.springframework.test.context.TestPropertySource
"management.security.enabled=false"
]
)
@Import(TestSecurityConfig::class)
class KeycloakGatewayIntegrationTest {
@Test
@@ -0,0 +1,59 @@
package at.mocode.infrastructure.gateway.config
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.config.web.server.invoke
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder
import org.springframework.security.web.server.SecurityWebFilterChain
import reactor.core.publisher.Mono
import java.time.Instant
/**
* Test-Konfiguration für Security-Beans.
* Stellt einen Mock ReactiveJwtDecoder und eine Security-Konfiguration bereit,
* die alle Anfragen für Test-Zwecke erlaubt.
*/
@TestConfiguration
class TestSecurityConfig {
/**
* Mock ReactiveJwtDecoder für Tests.
* Validiert keine echten JWTs, sondern akzeptiert alle Token für Test-Zwecke.
*/
@Bean
@Primary
fun mockReactiveJwtDecoder(): ReactiveJwtDecoder {
return ReactiveJwtDecoder { token ->
// Erstelle ein Mock-JWT mit minimalen Claims
val jwt = Jwt.withTokenValue(token)
.header("alg", "none")
.header("typ", "JWT")
.claim("sub", "test-user")
.claim("scope", "read write")
.claim("preferred_username", "test-user")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build()
Mono.just(jwt)
}
}
/**
* Test Security Web Filter Chain, die alle Anfragen erlaubt.
* Dies ermöglicht Tests von Routing, CORS und Filtern ohne Authentifizierung.
*/
@Bean
@Primary
fun testSecurityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
return http {
csrf { disable() }
authorizeExchange {
authorize(anyExchange, permitAll)
}
}
}
}