From 3e3af214e61d03b44f0cfa2fd76543f7e562c1cc Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Thu, 2 Oct 2025 00:52:24 +0200 Subject: [PATCH] fixing Keycloak Auth --- .env | 190 +++++++- README.md | 2 +- docker-compose.yml | 66 ++- .../services/keycloak/meldestelle-realm.json | 297 ++++++++++++ .../postgres/01-init-keycloak-schema.sql | 31 ++ .../infrastructure/auth-server/Dockerfile | 4 + dockerfiles/infrastructure/gateway/Dockerfile | 3 + .../infrastructure/keycloak/Dockerfile | 45 ++ .../monitoring-server/Dockerfile | 4 + .../services/events-service/Dockerfile | 4 + .../services/horses-service/Dockerfile | 4 + .../services/masterdata-service/Dockerfile | 4 + .../services/members-service/Dockerfile | 4 + dockerfiles/services/ping-service/Dockerfile | 3 + docs/KEYCLOAK-SETUP.md | 458 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- .../filter/KeycloakJwtAuthenticationFilter.kt | 196 -------- .../KeycloakJwtAuthenticationFilter.kt | 184 ------- .../gateway/security/SecurityConfig.kt | 45 +- .../main/resources/application-keycloak.yml | 37 +- .../src/test/resources/application-test.yml | 10 +- 21 files changed, 1155 insertions(+), 438 deletions(-) create mode 100644 docker/services/keycloak/meldestelle-realm.json create mode 100644 docker/services/postgres/01-init-keycloak-schema.sql create mode 100644 dockerfiles/infrastructure/keycloak/Dockerfile create mode 100644 docs/KEYCLOAK-SETUP.md delete mode 100644 infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/filter/KeycloakJwtAuthenticationFilter.kt delete mode 100644 infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/KeycloakJwtAuthenticationFilter.kt 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>(decoded) - } - - private fun extractRoles(claims: Map): List { - return try { - @Suppress("UNCHECKED_CAST") - val realmAccess = claims["realm_access"] as? Map - @Suppress("UNCHECKED_CAST") - val roles = realmAccess?.get("roles") as? List - roles ?: emptyList() - } 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 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 -} diff --git a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt b/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt index 3fac29c6..5b90fa3b 100644 --- a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt +++ b/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt @@ -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() diff --git a/infrastructure/gateway/src/main/resources/application-keycloak.yml b/infrastructure/gateway/src/main/resources/application-keycloak.yml index 4a4b3c6e..a748b928 100644 --- a/infrastructure/gateway/src/main/resources/application-keycloak.yml +++ b/infrastructure/gateway/src/main/resources/application-keycloak.yml @@ -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/**" diff --git a/infrastructure/gateway/src/test/resources/application-test.yml b/infrastructure/gateway/src/test/resources/application-test.yml index d874f348..efa05a8e 100644 --- a/infrastructure/gateway/src/test/resources/application-test.yml +++ b/infrastructure/gateway/src/test/resources/application-test.yml @@ -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