diff --git a/.env b/.env
index 377debf7..0287faf3 120000
--- a/.env
+++ b/.env
@@ -1 +1,189 @@
-config/.env.dev
\ No newline at end of file
+# =============================================================================
+# Meldestelle - Development Environment Configuration
+# =============================================================================
+# Development-specific environment variables
+# =============================================================================
+
+# =============================================================================
+# 1. APPLICATION CONFIGURATION
+# =============================================================================
+APP_NAME=Meldestelle
+APP_VERSION=1.0.0
+APP_DESCRIPTION='Pferdesport Meldestelle System'
+APP_ENVIRONMENT=development
+APP_HOST=0.0.0.0
+
+# Development-specific settings
+DEBUG_MODE=true
+DEV_HOT_RELOAD=true
+
+# =============================================================================
+# 2. PORT MANAGEMENT
+# =============================================================================
+# Gateway Ports
+GATEWAY_PORT=8081
+GATEWAY_ADMIN_PORT=8080
+
+# Service Ports
+PING_SERVICE_PORT=8082
+MEMBERS_SERVICE_PORT=8083
+HORSES_SERVICE_PORT=8084
+EVENTS_SERVICE_PORT=8085
+MASTERDATA_SERVICE_PORT=8086
+AUTH_SERVICE_PORT=8087
+
+# Client Application Ports
+WEB_APP_PORT=4000
+DESKTOP_VNC_PORT=5901
+DESKTOP_WEB_VNC_PORT=6080
+
+# Infrastructure Ports
+CONSUL_PORT=8500
+REDIS_PORT=6379
+KAFKA_PORT=9092
+PROMETHEUS_PORT=9090
+GRAFANA_PORT=3000
+
+# =============================================================================
+# 3. DATABASE CONFIGURATION
+# =============================================================================
+DB_HOST=localhost
+DB_PORT=5432
+DB_NAME=meldestelle
+DB_USER=meldestelle
+DB_PASSWORD=meldestelle
+DB_MAX_POOL_SIZE=10
+DB_MIN_POOL_SIZE=5
+DB_AUTO_MIGRATE=true
+
+POSTGRES_USER=meldestelle
+POSTGRES_PASSWORD=meldestelle
+POSTGRES_DB=meldestelle
+POSTGRES_EXTERNAL_PORT=5432
+
+# =============================================================================
+# 4. REDIS CONFIGURATION
+# =============================================================================
+REDIS_EVENT_STORE_HOST=localhost
+REDIS_EVENT_STORE_PORT=6379
+REDIS_EVENT_STORE_PASSWORD=
+REDIS_EVENT_STORE_DATABASE=0
+REDIS_EVENT_STORE_CONNECTION_TIMEOUT=2000
+REDIS_EVENT_STORE_READ_TIMEOUT=2000
+REDIS_EVENT_STORE_USE_POOLING=true
+REDIS_EVENT_STORE_MAX_POOL_SIZE=8
+REDIS_EVENT_STORE_MIN_POOL_SIZE=2
+
+REDIS_CACHE_HOST=localhost
+REDIS_CACHE_PORT=6379
+REDIS_CACHE_PASSWORD=
+REDIS_CACHE_DATABASE=1
+
+REDIS_EXTERNAL_PORT=6379
+REDIS_PASSWORD=
+
+# =============================================================================
+# 5. SECURITY CONFIGURATION
+# =============================================================================
+JWT_SECRET=meldestelle-jwt-secret-key-for-development-change-in-production
+JWT_ISSUER=meldestelle-api
+JWT_AUDIENCE=meldestelle-clients
+JWT_REALM=meldestelle
+API_KEY=meldestelle-api-key-for-development
+
+# =============================================================================
+# 6. KEYCLOAK CONFIGURATION
+# =============================================================================
+KEYCLOAK_ADMIN=admin
+KEYCLOAK_ADMIN_PASSWORD=admin
+KC_DB=postgres
+KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
+KC_DB_USERNAME=meldestelle
+KC_DB_PASSWORD=meldestelle
+KC_HOSTNAME=auth.meldestelle.local
+
+# =============================================================================
+# 7. SERVICE DISCOVERY
+# =============================================================================
+CONSUL_HOST=consul
+CONSUL_ENABLED=true
+SERVICE_DISCOVERY_ENABLED=true
+SERVICE_DISCOVERY_REGISTER_SERVICES=true
+SERVICE_DISCOVERY_HEALTH_CHECK_PATH=/health
+SERVICE_DISCOVERY_HEALTH_CHECK_INTERVAL=10
+
+# =============================================================================
+# 8. MESSAGING (Kafka)
+# =============================================================================
+ZOOKEEPER_CLIENT_PORT=2181
+KAFKA_BROKER_ID=1
+KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
+KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
+KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
+KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT
+KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
+
+# =============================================================================
+# 9. MONITORING
+# =============================================================================
+GF_SECURITY_ADMIN_USER=meldestelle
+GF_SECURITY_ADMIN_PASSWORD=meldestelle
+GF_USERS_ALLOW_SIGN_UP=false
+
+METRICS_AUTH_USERNAME=admin
+METRICS_AUTH_PASSWORD=metrics
+
+GRAFANA_HOSTNAME=grafana.meldestelle.local
+PROMETHEUS_HOSTNAME=prometheus.meldestelle.local
+
+# =============================================================================
+# 10. LOGGING CONFIGURATION
+# =============================================================================
+LOGGING_LEVEL=DEBUG
+LOGGING_REQUESTS=true
+LOGGING_RESPONSES=true
+LOGGING_REQUEST_HEADERS=true
+LOGGING_REQUEST_BODY=true
+LOGGING_RESPONSE_HEADERS=true
+LOGGING_RESPONSE_BODY=true
+LOGGING_STRUCTURED=true
+LOGGING_CORRELATION_ID=true
+LOGGING_REQUEST_ID_HEADER=X-Request-ID
+
+# =============================================================================
+# 11. CORS AND RATE LIMITING
+# =============================================================================
+SERVER_CORS_ENABLED=true
+SERVER_CORS_ALLOWED_ORIGINS=*
+RATELIMIT_ENABLED=true
+RATELIMIT_GLOBAL_LIMIT=100
+RATELIMIT_GLOBAL_PERIOD_MINUTES=1
+RATELIMIT_INCLUDE_HEADERS=true
+
+# =============================================================================
+# 12. DOCKER BUILD ARGUMENTS
+# =============================================================================
+# Centralized Docker build arguments for compose files
+# These mirror the values from docker/build-args/ for standalone compose usage
+DOCKER_GRADLE_VERSION=9.0.0
+DOCKER_JAVA_VERSION=21
+DOCKER_NODE_VERSION=20.11.0
+DOCKER_NGINX_VERSION=1.25-alpine
+DOCKER_APP_VERSION=1.0.0
+BUILD_DATE=2025-09-13T23:32:00Z
+
+# Monitoring & Infrastructure versions
+DOCKER_PROMETHEUS_VERSION=v2.54.1
+DOCKER_GRAFANA_VERSION=11.3.0
+DOCKER_KEYCLOAK_VERSION=26.0.7
+
+# Spring profiles for Docker builds
+DOCKER_SPRING_PROFILES_DEFAULT=default
+DOCKER_SPRING_PROFILES_DOCKER=docker
+
+# =============================================================================
+# 13. SPRING PROFILES AND GATEWAY
+# =============================================================================
+SPRING_PROFILES_ACTIVE=dev
+GATEWAY_ADMIN_USER=admin
+GATEWAY_ADMIN_PASSWORD=admin
diff --git a/README.md b/README.md
index 5168e38c..0ff808a7 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Das Projekt wurde kürzlich auf eine modulare Architektur migriert, um die Wartb
- Java 21
- Kotlin 2.2.10
-- Gradle 8.11+ (automatischer Download über Gradle Wrapper)
+- Gradle 9.0.0 (automatischer Download über Gradle Wrapper)
- Docker und Docker Compose (v2.0+)
## Infrastruktur
diff --git a/docker-compose.yml b/docker-compose.yml
index 165db28b..1e8b6bf4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -54,13 +54,15 @@ services:
restart: unless-stopped
# ===================================================================
- # Authentifizierung
+ # Authentifizierung - Keycloak
+ # ===================================================================
+ # Production-ready Keycloak configuration with optimized settings
# ===================================================================
keycloak:
- image: quay.io/keycloak/keycloak:${DOCKER_KEYCLOAK_VERSION:-26.0.7}
+ image: quay.io/keycloak/keycloak:${DOCKER_KEYCLOAK_VERSION:-26.4.0}
container_name: meldestelle-keycloak
environment:
- # Admin Configuration
+ # Admin Configuration - CHANGE IN PRODUCTION!
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
@@ -70,21 +72,43 @@ services:
KC_DB_USERNAME: ${POSTGRES_USER:-meldestelle}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-meldestelle}
KC_DB_SCHEMA: keycloak
+ # Database connection pool optimization
+ KC_DB_POOL_INITIAL_SIZE: ${KC_DB_POOL_INITIAL_SIZE:-5}
+ KC_DB_POOL_MIN_SIZE: ${KC_DB_POOL_MIN_SIZE:-5}
+ KC_DB_POOL_MAX_SIZE: ${KC_DB_POOL_MAX_SIZE:-20}
- # Keycloak Configuration
+ # Keycloak Server Configuration
KC_HTTP_PORT: 8080
- KC_HOSTNAME_STRICT: false
- KC_HOSTNAME_STRICT_HTTPS: false
- KC_HTTP_ENABLED: true
- KC_PROXY: edge
+ KC_HOSTNAME_STRICT: ${KC_HOSTNAME_STRICT:-false}
+ KC_HOSTNAME_STRICT_HTTPS: ${KC_HOSTNAME_STRICT_HTTPS:-false}
+ KC_HTTP_ENABLED: ${KC_HTTP_ENABLED:-true}
+ KC_PROXY: ${KC_PROXY:-edge}
+ KC_PROXY_HEADERS: ${KC_PROXY_HEADERS:-xforwarded}
- # Development Settings
- KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
- KC_METRICS_ENABLED: true
- KC_HEALTH_ENABLED: true
+ # Logging Configuration
+ KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-info}
+ KC_LOG_CONSOLE_COLOR: ${KC_LOG_CONSOLE_COLOR:-false}
+ KC_LOG_CONSOLE_FORMAT: ${KC_LOG_CONSOLE_FORMAT:-json}
+
+ # Metrics and Health
+ KC_METRICS_ENABLED: ${KC_METRICS_ENABLED:-true}
+ KC_HEALTH_ENABLED: ${KC_HEALTH_ENABLED:-true}
+
+ # Cache Configuration (Infinispan)
+ KC_CACHE: ${KC_CACHE:-ispn}
+ KC_CACHE_STACK: ${KC_CACHE_STACK:-tcp}
+
+ # JVM Optimization for containers
+ JAVA_OPTS_APPEND: >-
+ -XX:MaxRAMPercentage=75.0
+ -XX:+UseG1GC
+ -XX:+UseStringDeduplication
+ -XX:+DisableExplicitGC
+ -Djava.net.preferIPv4Stack=true
+ -Duser.timezone=Europe/Vienna
ports:
- - "8180:8080"
+ - "${KEYCLOAK_PORT:-8180}:8080"
depends_on:
postgres:
condition: service_healthy
@@ -92,17 +116,20 @@ services:
- ./docker/services/keycloak:/opt/keycloak/data/import
- keycloak-data:/opt/keycloak/data
command:
- - start-dev
+ # Production mode with optimizations
+ - start
+ - --optimized
- --import-realm
- --http-port=8080
+# - --http-relative-path=/auth
networks:
- meldestelle-network
healthcheck:
- test: [ "CMD", "curl", "-f", "http://localhost:8080/health/ready" ]
+ test: [ "CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'" ]
interval: 30s
timeout: 10s
retries: 5
- start_period: 60s
+ start_period: 90s
restart: unless-stopped
# ===================================================================
@@ -251,13 +278,14 @@ services:
CONSUL_PORT: ${CONSUL_PORT:-8500}
CONSUL_ENABLED: "true"
GATEWAY_PORT: ${GATEWAY_PORT:-8081}
- # Keycloak-Integration
+ # Keycloak OAuth2 Integration (using Spring Security oauth2ResourceServer)
KEYCLOAK_SERVER_URL: http://keycloak:8080
KEYCLOAK_ISSUER_URI: http://keycloak:8080/realms/meldestelle
KEYCLOAK_JWK_SET_URI: http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs
KEYCLOAK_REALM: meldestelle
KEYCLOAK_CLIENT_ID: api-gateway
- GATEWAY_SECURITY_KEYCLOAK_ENABLED: "true"
+ # Custom JWT filter disabled - using oauth2ResourceServer instead
+ GATEWAY_SECURITY_KEYCLOAK_ENABLED: "false"
ports:
- "${GATEWAY_PORT:-8081}:8081"
depends_on:
@@ -267,6 +295,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ keycloak:
+ condition: service_healthy
networks:
- meldestelle-network
healthcheck:
diff --git a/docker/services/keycloak/meldestelle-realm.json b/docker/services/keycloak/meldestelle-realm.json
new file mode 100644
index 00000000..71daa189
--- /dev/null
+++ b/docker/services/keycloak/meldestelle-realm.json
@@ -0,0 +1,297 @@
+{
+ "realm": "meldestelle",
+ "enabled": true,
+ "displayName": "Meldestelle Authentication",
+ "displayNameHtml": "
Meldestelle
",
+ "sslRequired": "external",
+ "registrationAllowed": false,
+ "registrationEmailAsUsername": false,
+ "rememberMe": true,
+ "verifyEmail": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": true,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": true,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 5,
+ "defaultSignatureAlgorithm": "RS256",
+ "offlineSessionMaxLifespan": 5184000,
+ "offlineSessionMaxLifespanEnabled": false,
+ "accessTokenLifespan": 300,
+ "accessTokenLifespanForImplicitFlow": 900,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "refreshTokenMaxReuse": 0,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "actionTokenGeneratedByAdminLifespan": 43200,
+ "actionTokenGeneratedByUserLifespan": 300,
+ "oauth2DeviceCodeLifespan": 600,
+ "oauth2DevicePollingInterval": 5,
+ "internationalizationEnabled": true,
+ "supportedLocales": ["de", "en"],
+ "defaultLocale": "de",
+ "roles": {
+ "realm": [
+ {
+ "name": "ADMIN",
+ "description": "Administrator role with full system access",
+ "composite": false,
+ "clientRole": false
+ },
+ {
+ "name": "USER",
+ "description": "Standard user role with limited access",
+ "composite": false,
+ "clientRole": false
+ },
+ {
+ "name": "MONITORING",
+ "description": "Monitoring role for system health checks",
+ "composite": false,
+ "clientRole": false
+ },
+ {
+ "name": "GUEST",
+ "description": "Guest role with minimal access",
+ "composite": false,
+ "clientRole": false
+ }
+ ]
+ },
+ "clients": [
+ {
+ "clientId": "api-gateway",
+ "name": "API Gateway Client",
+ "description": "OAuth2 client for the Meldestelle API Gateway",
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "**********",
+ "redirectUris": [
+ "http://localhost:8081/*",
+ "http://localhost:3000/*",
+ "https://app.meldestelle.at/*"
+ ],
+ "webOrigins": [
+ "http://localhost:8081",
+ "http://localhost:3000",
+ "https://app.meldestelle.at"
+ ],
+ "protocol": "openid-connect",
+ "bearerOnly": false,
+ "publicClient": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": true,
+ "authorizationServicesEnabled": false,
+ "fullScopeAllowed": true,
+ "frontchannelLogout": true,
+ "attributes": {
+ "access.token.lifespan": "300",
+ "client.secret.creation.time": "0",
+ "oauth2.device.authorization.grant.enabled": "false",
+ "backchannel.logout.session.required": "true",
+ "backchannel.logout.revoke.offline.tokens": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "realm-roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "client-roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "resource_access.${client_id}.roles",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "full-name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-full-name-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "clientId": "web-app",
+ "name": "Web Application Client",
+ "description": "Public client for web frontend",
+ "enabled": true,
+ "publicClient": true,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "redirectUris": [
+ "http://localhost:3000/*",
+ "https://app.meldestelle.at/*"
+ ],
+ "webOrigins": [
+ "http://localhost:3000",
+ "https://app.meldestelle.at"
+ ],
+ "protocol": "openid-connect",
+ "attributes": {
+ "pkce.code.challenge.method": "S256"
+ }
+ }
+ ],
+ "users": [
+ {
+ "username": "admin",
+ "enabled": true,
+ "emailVerified": true,
+ "firstName": "System",
+ "lastName": "Administrator",
+ "email": "admin@meldestelle.local",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "Change_Me_In_Production!",
+ "temporary": true
+ }
+ ],
+ "realmRoles": ["ADMIN", "USER"],
+ "clientRoles": {
+ "api-gateway": ["ADMIN"]
+ }
+ }
+ ],
+ "groups": [],
+ "defaultRoles": ["USER", "GUEST"],
+ "requiredCredentials": ["password"],
+ "passwordPolicy": "length(8) and digits(1) and lowerCase(1) and upperCase(1) and specialChars(1) and notUsername",
+ "otpPolicyType": "totp",
+ "otpPolicyAlgorithm": "HmacSHA1",
+ "otpPolicyInitialCounter": 0,
+ "otpPolicyDigits": 6,
+ "otpPolicyLookAheadWindow": 1,
+ "otpPolicyPeriod": 30,
+ "otpSupportedApplications": ["FreeOTP", "Google Authenticator"],
+ "webAuthnPolicyRpEntityName": "meldestelle",
+ "webAuthnPolicySignatureAlgorithms": ["ES256", "RS256"],
+ "smtpServer": {},
+ "eventsEnabled": true,
+ "eventsListeners": ["jboss-logging"],
+ "enabledEventTypes": [
+ "LOGIN",
+ "LOGIN_ERROR",
+ "LOGOUT",
+ "REGISTER",
+ "REGISTER_ERROR",
+ "UPDATE_PASSWORD",
+ "UPDATE_PASSWORD_ERROR"
+ ],
+ "adminEventsEnabled": true,
+ "adminEventsDetailsEnabled": true,
+ "identityProviders": [],
+ "identityProviderMappers": [],
+ "components": {
+ "org.keycloak.keys.KeyProvider": [
+ {
+ "name": "rsa-generated",
+ "providerId": "rsa-generated",
+ "subComponents": {},
+ "config": {
+ "priority": ["100"]
+ }
+ },
+ {
+ "name": "hmac-generated",
+ "providerId": "hmac-generated",
+ "subComponents": {},
+ "config": {
+ "priority": ["100"],
+ "algorithm": ["HS256"]
+ }
+ },
+ {
+ "name": "aes-generated",
+ "providerId": "aes-generated",
+ "subComponents": {},
+ "config": {
+ "priority": ["100"]
+ }
+ }
+ ]
+ },
+ "internationalizationEnabled": true,
+ "supportedLocales": ["de", "en"],
+ "defaultLocale": "de",
+ "authenticationFlows": [],
+ "authenticatorConfig": [],
+ "requiredActions": [],
+ "browserFlow": "browser",
+ "registrationFlow": "registration",
+ "directGrantFlow": "direct grant",
+ "resetCredentialsFlow": "reset credentials",
+ "clientAuthenticationFlow": "clients",
+ "dockerAuthenticationFlow": "docker auth",
+ "attributes": {
+ "frontendUrl": "",
+ "acr.loa.map": "{}",
+ "clientOfflineSessionMaxLifespan": "0",
+ "clientSessionIdleTimeout": "0",
+ "clientSessionMaxLifespan": "0",
+ "clientOfflineSessionIdleTimeout": "0"
+ }
+}
diff --git a/docker/services/postgres/01-init-keycloak-schema.sql b/docker/services/postgres/01-init-keycloak-schema.sql
new file mode 100644
index 00000000..ce9cc7cd
--- /dev/null
+++ b/docker/services/postgres/01-init-keycloak-schema.sql
@@ -0,0 +1,31 @@
+-- ===================================================================
+-- PostgreSQL Initialization Script for Keycloak
+-- ===================================================================
+-- This script creates a separate schema for Keycloak data within the
+-- meldestelle database, providing isolation and better organization.
+--
+-- Execution: Automatically executed by PostgreSQL on first startup
+-- via docker-entrypoint-initdb.d mechanism.
+-- ===================================================================
+
+-- Create Keycloak schema if it doesn't exist
+CREATE SCHEMA IF NOT EXISTS keycloak;
+
+-- Grant all privileges on the schema to the meldestelle user
+GRANT ALL PRIVILEGES ON SCHEMA keycloak TO meldestelle;
+
+-- Grant usage on the schema
+GRANT USAGE ON SCHEMA keycloak TO meldestelle;
+
+-- Set default privileges for future tables in the keycloak schema
+ALTER DEFAULT PRIVILEGES IN SCHEMA keycloak GRANT ALL ON TABLES TO meldestelle;
+ALTER DEFAULT PRIVILEGES IN SCHEMA keycloak GRANT ALL ON SEQUENCES TO meldestelle;
+ALTER DEFAULT PRIVILEGES IN SCHEMA keycloak GRANT ALL ON FUNCTIONS TO meldestelle;
+
+-- Log successful schema creation
+DO $$
+BEGIN
+ RAISE NOTICE 'Keycloak schema created successfully';
+ RAISE NOTICE 'Schema: keycloak';
+ RAISE NOTICE 'Owner: meldestelle';
+END $$;
diff --git a/dockerfiles/infrastructure/auth-server/Dockerfile b/dockerfiles/infrastructure/auth-server/Dockerfile
index 81eddc68..b2b73394 100644
--- a/dockerfiles/infrastructure/auth-server/Dockerfile
+++ b/dockerfiles/infrastructure/auth-server/Dockerfile
@@ -51,6 +51,10 @@ ENV GRADLE_USER_HOME=/home/gradle/.gradle
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
diff --git a/dockerfiles/infrastructure/gateway/Dockerfile b/dockerfiles/infrastructure/gateway/Dockerfile
index 5bed57d2..e0c5c880 100644
--- a/dockerfiles/infrastructure/gateway/Dockerfile
+++ b/dockerfiles/infrastructure/gateway/Dockerfile
@@ -51,6 +51,9 @@ ENV GRADLE_USER_HOME=/home/gradle/.gradle
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
# Copy platform dependencies (changes less frequently)
COPY platform/ platform/
COPY core/ core/
diff --git a/dockerfiles/infrastructure/keycloak/Dockerfile b/dockerfiles/infrastructure/keycloak/Dockerfile
new file mode 100644
index 00000000..077e0b23
--- /dev/null
+++ b/dockerfiles/infrastructure/keycloak/Dockerfile
@@ -0,0 +1,45 @@
+# syntax=docker/dockerfile:1.8
+# ===================================================================
+# Production-Ready Keycloak Dockerfile
+# ===================================================================
+# Based on: quay.io/keycloak/keycloak:26.0.7
+# Features:
+# - Pre-built optimized image (faster startup)
+# - Security hardening
+# - Custom theme support
+# - Health monitoring
+# ===================================================================
+
+ARG KEYCLOAK_VERSION=26.4.0
+
+# Build stage - optimize Keycloak
+FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} AS builder
+
+ENV KC_HEALTH_ENABLED=true
+ENV KC_METRICS_ENABLED=true
+ENV KC_DB=postgres
+
+WORKDIR /opt/keycloak
+
+# Pre-build Keycloak for faster startup
+RUN /opt/keycloak/bin/kc.sh build \
+ --db=postgres \
+ --health-enabled=true \
+ --metrics-enabled=true \
+ --cache=ispn \
+ --cache-stack=tcp
+
+# Production stage
+FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}
+
+LABEL maintainer="Meldestelle Development Team"
+LABEL description="Production-ready Keycloak for Meldestelle authentication"
+LABEL version="${KEYCLOAK_VERSION}"
+
+# Copy pre-built Keycloak
+COPY --from=builder /opt/keycloak/ /opt/keycloak/
+
+# Set user
+USER 1000
+
+ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
diff --git a/dockerfiles/infrastructure/monitoring-server/Dockerfile b/dockerfiles/infrastructure/monitoring-server/Dockerfile
index dba7a362..e8580628 100644
--- a/dockerfiles/infrastructure/monitoring-server/Dockerfile
+++ b/dockerfiles/infrastructure/monitoring-server/Dockerfile
@@ -37,6 +37,10 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
diff --git a/dockerfiles/services/events-service/Dockerfile b/dockerfiles/services/events-service/Dockerfile
index 32acee4c..766c3415 100644
--- a/dockerfiles/services/events-service/Dockerfile
+++ b/dockerfiles/services/events-service/Dockerfile
@@ -45,6 +45,10 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
diff --git a/dockerfiles/services/horses-service/Dockerfile b/dockerfiles/services/horses-service/Dockerfile
index d2667555..63f5b30d 100644
--- a/dockerfiles/services/horses-service/Dockerfile
+++ b/dockerfiles/services/horses-service/Dockerfile
@@ -45,6 +45,10 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
diff --git a/dockerfiles/services/masterdata-service/Dockerfile b/dockerfiles/services/masterdata-service/Dockerfile
index 2f0dad3b..5c841bce 100644
--- a/dockerfiles/services/masterdata-service/Dockerfile
+++ b/dockerfiles/services/masterdata-service/Dockerfile
@@ -45,6 +45,10 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
diff --git a/dockerfiles/services/members-service/Dockerfile b/dockerfiles/services/members-service/Dockerfile
index 3f4a5ad0..4c27565b 100644
--- a/dockerfiles/services/members-service/Dockerfile
+++ b/dockerfiles/services/members-service/Dockerfile
@@ -45,6 +45,10 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
diff --git a/dockerfiles/services/ping-service/Dockerfile b/dockerfiles/services/ping-service/Dockerfile
index 7c2186fa..7a3bb7c2 100644
--- a/dockerfiles/services/ping-service/Dockerfile
+++ b/dockerfiles/services/ping-service/Dockerfile
@@ -45,6 +45,9 @@ ENV GRADLE_USER_HOME=/home/gradle/.gradle
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
+# Make gradlew executable (required on Linux/Unix systems)
+RUN chmod +x gradlew
+
# Copy platform dependencies (changes less frequently)
COPY platform/ platform/
diff --git a/docs/KEYCLOAK-SETUP.md b/docs/KEYCLOAK-SETUP.md
new file mode 100644
index 00000000..ca7ade4c
--- /dev/null
+++ b/docs/KEYCLOAK-SETUP.md
@@ -0,0 +1,458 @@
+# Keycloak Integration - Setup und Konfiguration
+
+## Übersicht
+
+Dieses Dokument beschreibt die vollständige Keycloak-Integration für das Meldestelle-System, einschließlich Authentifizierung, Konfiguration und Best Practices.
+
+## Architektur
+
+### Authentifizierungsansatz
+
+Das System verwendet **Spring Security OAuth2 Resource Server** für die JWT-Validierung:
+
+- ✅ **Empfohlener Ansatz**: Spring Security `oauth2ResourceServer`
+ - Kryptographisch sichere JWT-Signaturvalidierung
+ - Automatische JWK-Set-Aktualisierung
+ - Standardkonform (RFC 7519, RFC 7517)
+ - Integriert mit Spring Security Authorization
+
+- ❌ **NICHT verwendet**: Custom JWT Filter
+ - Frühere Implementierungen wurden entfernt
+ - Hatten Sicherheitslücken (fehlende Signaturvalidierung)
+
+### Komponenten
+
+1. **Keycloak Server** (Port 8180 extern, 8080 intern)
+ - OAuth2/OpenID Connect Provider
+ - PostgreSQL Backend
+ - Realm: `meldestelle`
+
+2. **API Gateway**
+ - OAuth2 Resource Server
+ - JWT-Validierung via JWK-Set
+ - Rollenbasierte Autorisierung
+
+3. **PostgreSQL Database**
+ - Keycloak-Schema: `keycloak`
+ - Automatische Schema-Initialisierung
+
+## Konfiguration
+
+### Docker Compose
+
+#### Keycloak Service
+
+```yaml
+keycloak:
+ image: quay.io/keycloak/keycloak:26.0.7
+ environment:
+ # Admin-Zugangsdaten (IN PRODUKTION ÄNDERN!)
+ KEYCLOAK_ADMIN: admin
+ KEYCLOAK_ADMIN_PASSWORD: admin
+
+ # Datenbank
+ KC_DB: postgres
+ KC_DB_URL: jdbc:postgresql://postgres:5432/meldestelle
+ KC_DB_SCHEMA: keycloak
+
+ # Connection Pool Optimierung
+ KC_DB_POOL_INITIAL_SIZE: 5
+ KC_DB_POOL_MIN_SIZE: 5
+ KC_DB_POOL_MAX_SIZE: 20
+
+ # JVM Optimierung
+ JAVA_OPTS_APPEND: >-
+ -XX:MaxRAMPercentage=75.0
+ -XX:+UseG1GC
+ -XX:+UseStringDeduplication
+```
+
+#### Produktionsmodus
+
+Der Service läuft im **Produktionsmodus** (`start --optimized`):
+- Schnellerer Start durch Pre-Build
+- Optimierte Performance
+- Geeignet für Produktionsumgebungen
+
+**Wichtig**: Für Entwicklung kann auf `start-dev` umgestellt werden.
+
+### Realm-Konfiguration
+
+**Datei**: `docker/services/keycloak/meldestelle-realm.json`
+
+#### Realm: `meldestelle`
+
+- **Display Name**: Meldestelle Authentication
+- **Sprachen**: Deutsch (Standard), Englisch
+- **SSL**: External (hinter Reverse Proxy)
+
+#### Sicherheitseinstellungen
+
+- **Brute Force Protection**: Aktiviert
+ - Max. 5 Fehlversuche
+ - 15 Minuten Sperrzeit
+
+- **Password Policy**:
+ - Mindestens 8 Zeichen
+ - Mind. 1 Ziffer, 1 Kleinbuchstabe, 1 Großbuchstabe, 1 Sonderzeichen
+ - Nicht identisch mit Username
+
+#### Token-Einstellungen
+
+- **Access Token Lifespan**: 5 Minuten (300 Sek.)
+- **SSO Session Idle**: 30 Minuten (1800 Sek.)
+- **SSO Session Max**: 10 Stunden (36000 Sek.)
+- **Refresh Token**: Einmalige Verwendung
+
+### Clients
+
+#### 1. api-gateway (Confidential Client)
+
+```json
+{
+ "clientId": "api-gateway",
+ "protocol": "openid-connect",
+ "publicClient": false,
+ "bearerOnly": false,
+ "standardFlowEnabled": true,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": true
+}
+```
+
+**Verwendung**: Backend-Service-to-Service Kommunikation
+
+**Secret**: Muss in Keycloak UI generiert und konfiguriert werden
+
+#### 2. web-app (Public Client)
+
+```json
+{
+ "clientId": "web-app",
+ "publicClient": true,
+ "standardFlowEnabled": true,
+ "attributes": {
+ "pkce.code.challenge.method": "S256"
+ }
+}
+```
+
+**Verwendung**: Frontend Single-Page Application (mit PKCE)
+
+### Rollen
+
+| Rolle | Beschreibung | Verwendung |
+|-------|--------------|------------|
+| `ADMIN` | Vollzugriff | Systemadministration |
+| `USER` | Standardbenutzer | Normale Anwendungsfunktionen |
+| `MONITORING` | Überwachung | Metriken und Health Checks |
+| `GUEST` | Gast | Minimaler Zugriff |
+
+### Standard-Benutzer
+
+**Username**: `admin`
+**Passwort**: `Change_Me_In_Production!` (temporär, muss beim ersten Login geändert werden)
+**Rollen**: ADMIN, USER
+
+## Spring Security Konfiguration
+
+### Gateway SecurityConfig
+
+**Datei**: `infrastructure/gateway/src/main/kotlin/.../SecurityConfig.kt`
+
+```kotlin
+@Bean
+fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
+ return http
+ .oauth2ResourceServer { oauth2 ->
+ oauth2.jwt { jwt ->
+ jwt.jwtDecoder(jwtDecoder())
+ }
+ }
+ .authorizeExchange { exchanges ->
+ exchanges
+ .pathMatchers("/api/admin/**").hasRole("ADMIN")
+ .pathMatchers("/api/monitoring/**").hasAnyRole("ADMIN", "MONITORING")
+ .anyExchange().authenticated()
+ }
+ .build()
+}
+```
+
+### Application Properties
+
+**Datei**: `application-keycloak.yml`
+
+```yaml
+spring:
+ security:
+ oauth2:
+ resourceserver:
+ jwt:
+ issuer-uri: http://keycloak:8080/realms/meldestelle
+ jwk-set-uri: http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs
+
+gateway:
+ security:
+ keycloak:
+ enabled: false # Custom filter deaktiviert - oauth2ResourceServer wird verwendet
+```
+
+## Datenbank-Setup
+
+### PostgreSQL Schema
+
+**Datei**: `docker/services/postgres/01-init-keycloak-schema.sql`
+
+Das Schema wird automatisch beim ersten Start von PostgreSQL erstellt:
+
+```sql
+CREATE SCHEMA IF NOT EXISTS keycloak;
+GRANT ALL PRIVILEGES ON SCHEMA keycloak TO meldestelle;
+```
+
+Keycloak erstellt seine Tabellen automatisch im `keycloak` Schema.
+
+## Produktion Deployment
+
+### Dockerfile
+
+**Optional**: `dockerfiles/infrastructure/keycloak/Dockerfile`
+
+Pre-built optimiertes Image für schnelleren Start:
+
+```dockerfile
+FROM quay.io/keycloak/keycloak:26.0.7 AS builder
+RUN /opt/keycloak/bin/kc.sh build --db=postgres
+```
+
+**Verwendung in docker-compose.yml**:
+
+```yaml
+keycloak:
+ build:
+ context: .
+ dockerfile: dockerfiles/infrastructure/keycloak/Dockerfile
+```
+
+### Umgebungsvariablen für Produktion
+
+Erstellen Sie eine `.env` Datei:
+
+```env
+# Keycloak Admin (ÄNDERN!)
+KEYCLOAK_ADMIN=admin
+KEYCLOAK_ADMIN_PASSWORD=
+
+# Datenbank
+POSTGRES_USER=meldestelle
+POSTGRES_PASSWORD=
+POSTGRES_DB=meldestelle
+
+# Keycloak Konfiguration
+KC_HOSTNAME_STRICT=true
+KC_HOSTNAME_STRICT_HTTPS=true
+KC_HTTP_ENABLED=false
+KC_PROXY=edge
+
+# JVM Memory (optional)
+KC_DB_POOL_MAX_SIZE=50
+```
+
+### SSL/TLS
+
+Für HTTPS in Produktion:
+
+1. **Empfohlen**: Reverse Proxy (nginx, Traefik)
+ ```yaml
+ KC_PROXY: edge
+ KC_HTTP_ENABLED: true
+ KC_HOSTNAME_STRICT_HTTPS: false
+ ```
+
+2. **Direkt mit Keycloak**:
+ ```yaml
+ KC_HTTPS_CERTIFICATE_FILE: /path/to/cert.pem
+ KC_HTTPS_CERTIFICATE_KEY_FILE: /path/to/key.pem
+ KC_HTTP_ENABLED: false
+ ```
+
+## Betrieb
+
+### Start
+
+```bash
+# Alle Services starten
+docker-compose up -d
+
+# Nur Infrastruktur (inkl. Keycloak)
+docker-compose up -d postgres redis keycloak consul
+```
+
+### Keycloak Admin Console
+
+**URL**: http://localhost:8180
+**Username**: admin
+**Passwort**: admin (Standard)
+
+### Health Checks
+
+```bash
+# Keycloak Readiness
+curl http://localhost:8180/health/ready
+
+# Keycloak Liveness
+curl http://localhost:8180/health/live
+
+# Metrics
+curl http://localhost:8180/metrics
+```
+
+### Logs
+
+```bash
+# Keycloak Logs anzeigen
+docker-compose logs -f keycloak
+
+# Letzte 100 Zeilen
+docker-compose logs --tail=100 keycloak
+```
+
+## Troubleshooting
+
+### Problem: Keycloak startet nicht
+
+**Symptom**: Container stoppt sofort nach Start
+
+**Lösung**:
+1. Prüfen Sie PostgreSQL Verbindung:
+ ```bash
+ docker-compose logs postgres
+ ```
+
+2. Schema-Berechtigungen prüfen:
+ ```sql
+ \dn+ keycloak
+ ```
+
+3. Keycloak Logs prüfen:
+ ```bash
+ docker-compose logs keycloak
+ ```
+
+### Problem: JWT Validierung schlägt fehl
+
+**Symptom**: 401 Unauthorized trotz gültigem Token
+
+**Lösung**:
+1. Issuer URI prüfen:
+ ```bash
+ curl http://keycloak:8080/realms/meldestelle/.well-known/openid-configuration
+ ```
+
+2. JWK-Set prüfen:
+ ```bash
+ curl http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs
+ ```
+
+3. Gateway-Logs prüfen:
+ ```bash
+ docker-compose logs api-gateway | grep -i jwt
+ ```
+
+### Problem: Realm nicht importiert
+
+**Symptom**: Realm `meldestelle` existiert nicht
+
+**Lösung**:
+1. Prüfen Sie Volume-Mount:
+ ```bash
+ docker-compose config | grep -A 5 "keycloak:" | grep volumes
+ ```
+
+2. Realm-Datei prüfen:
+ ```bash
+ ls -la docker/services/keycloak/
+ ```
+
+3. Container neu starten:
+ ```bash
+ docker-compose restart keycloak
+ ```
+
+## Migration von alter Implementierung
+
+### Entfernte Komponenten
+
+Die folgenden Dateien wurden entfernt (waren unsicher/redundant):
+
+- ❌ `infrastructure/gateway/security/KeycloakJwtAuthenticationFilter.kt`
+ - **Grund**: Keine kryptographische Signaturvalidierung
+ - **Ersetzt durch**: Spring Security oauth2ResourceServer
+
+- ❌ `infrastructure/gateway/filter/KeycloakJwtAuthenticationFilter.kt`
+ - **Grund**: Redundant zu oauth2ResourceServer
+ - **Ersetzt durch**: Spring Security oauth2ResourceServer
+
+### Konfigurationsänderungen
+
+**Alt**:
+```yaml
+gateway:
+ security:
+ keycloak:
+ enabled: true # Custom Filter
+```
+
+**Neu**:
+```yaml
+spring:
+ security:
+ oauth2:
+ resourceserver:
+ jwt:
+ issuer-uri: http://keycloak:8080/realms/meldestelle
+
+gateway:
+ security:
+ keycloak:
+ enabled: false # Spring Security oauth2ResourceServer verwenden
+```
+
+## Best Practices
+
+### Sicherheit
+
+1. ✅ **Verwenden Sie starke Admin-Passwörter**
+2. ✅ **Aktivieren Sie HTTPS in Produktion**
+3. ✅ **Regelmäßige Backups der Keycloak-Datenbank**
+4. ✅ **Überwachen Sie Failed Login Attempts**
+5. ✅ **Verwenden Sie Service Accounts für Backend-Services**
+
+### Performance
+
+1. ✅ **Database Connection Pool anpassen** (KC_DB_POOL_MAX_SIZE)
+2. ✅ **JVM Memory optimieren** (JAVA_OPTS_APPEND)
+3. ✅ **Pre-built Image verwenden** (Dockerfile)
+4. ✅ **Cache aktivieren** (KC_CACHE=ispn)
+
+### Monitoring
+
+1. ✅ **Metrics überwachen** (/metrics Endpoint)
+2. ✅ **Health Checks konfigurieren**
+3. ✅ **Event Logging aktivieren**
+4. ✅ **Admin Events protokollieren**
+
+## Weitere Ressourcen
+
+- [Keycloak Official Documentation](https://www.keycloak.org/documentation)
+- [Spring Security OAuth2 Resource Server](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html)
+- [OpenID Connect Specification](https://openid.net/specs/openid-connect-core-1_0.html)
+
+## Support
+
+Bei Problemen oder Fragen:
+
+1. Prüfen Sie die Logs
+2. Konsultieren Sie dieses Dokument
+3. Kontaktieren Sie das Development Team
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 215deeb0..f4c0cadb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,7 +47,7 @@ zipkinReporter = "3.5.1"
# --- Authentication ---
auth0Jwt = "4.5.0"
-keycloak = "25.0.6"
+keycloak = "26.4.0"
# --- Testing ---
junitJupiter = "5.12.2"
diff --git a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/filter/KeycloakJwtAuthenticationFilter.kt b/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/filter/KeycloakJwtAuthenticationFilter.kt
deleted file mode 100644
index 11106a1e..00000000
--- a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/filter/KeycloakJwtAuthenticationFilter.kt
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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
- }
-
- private fun extractRoles(claims: Map): List {
- return try {
- // Keycloak realm roles
- @Suppress("UNCHECKED_CAST")
- val realmAccess = claims["realm_access"] as? Map
- @Suppress("UNCHECKED_CAST")
- val realmRoles = realmAccess?.get("roles") as? List ?: emptyList()
-
- // Keycloak resource access (client-specific roles)
- @Suppress("UNCHECKED_CAST")
- val resourceAccess = claims["resource_access"] as? Map
- @Suppress("UNCHECKED_CAST")
- val clientAccess = resourceAccess?.get("api-gateway") as? Map
- @Suppress("UNCHECKED_CAST")
- val clientRoles = clientAccess?.get("roles") as? List ?: emptyList()
-
- (realmRoles + clientRoles).distinct()
- } catch (e: Exception) {
- logger.warn("Could not extract roles from token: {}", e.message)
- emptyList()
- }
- }
-
- private fun determineUserRole(roles: List): 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 {
- 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
-}
diff --git a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/KeycloakJwtAuthenticationFilter.kt b/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/KeycloakJwtAuthenticationFilter.kt
deleted file mode 100644
index 6c206fef..00000000
--- a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/KeycloakJwtAuthenticationFilter.kt
+++ /dev/null
@@ -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 {
- 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 {
-
- 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 {
- val parts = token.split(".")
- val payload = parts[1]
-
- // Base64 URL decode
- val decoded = Base64.getUrlDecoder().decode(payload)
- return objectMapper.readValue