Compare commits
11 Commits
f022de7358
...
d6a484c347
| Author | SHA1 | Date | |
|---|---|---|---|
| d6a484c347 | |||
| 4ae11e6668 | |||
| 23e08403f1 | |||
| 5bc2538aab | |||
| 69c1123586 | |||
| 15e253365a | |||
| b9a433f772 | |||
| 2db3fd82c5 | |||
| 218c7fa13a | |||
| 440ab4b141 | |||
| f47e5f6415 |
@@ -49,12 +49,12 @@ SPRING_DATA_VALKEY_PORT=6379
|
||||
SPRING_DATA_VALKEY_PASSWORD=valkey-password
|
||||
|
||||
# --- KEYCLOAK ---
|
||||
KEYCLOAK_IMAGE_TAG=26.4
|
||||
KEYCLOAK_IMAGE_TAG=26.5.5
|
||||
KC_HEAP_MIN=512M
|
||||
KC_HEAP_MAX=1024M
|
||||
# Lokale Entwicklung: start-dev (kein Pre-Build nötig, kein --optimized)
|
||||
# Server/Produktion: start --optimized --import-realm (nutzt das pre-built Registry-Image)
|
||||
KC_COMMAND=start-dev --import-realm
|
||||
KC_COMMAND=start --optimized --import-realm
|
||||
KC_ADMIN_USERNAME=kc-admin
|
||||
KC_ADMIN_PASSWORD=kc-password
|
||||
KC_DB=postgres
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
name: PR Guard
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "**" ]
|
||||
|
||||
jobs:
|
||||
no-hardcoded-versions:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "^http://localhost"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://localhost"
|
||||
},
|
||||
{
|
||||
"pattern": "^http://127.0.0.1"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://127.0.0.1"
|
||||
},
|
||||
{
|
||||
"pattern": "^XXXX-filename\\.md$"
|
||||
}
|
||||
],
|
||||
"replacementPatterns": [
|
||||
{
|
||||
"pattern": "^/",
|
||||
"replacement": "{{BASEURL}}/"
|
||||
}
|
||||
],
|
||||
"httpHeaders": [
|
||||
{
|
||||
"urls": ["https://github.com"],
|
||||
"headers": {
|
||||
"Accept": "text/html"
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeout": "20s",
|
||||
"retryOn429": true,
|
||||
"retryCount": 3,
|
||||
"fallbackRetryDelay": "30s",
|
||||
"aliveStatusCodes": [200, 206]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
## Ziel
|
||||
Kurze Beschreibung des Ziels/Problems und was dieser PR löst.
|
||||
|
||||
## Änderungen
|
||||
-
|
||||
|
||||
## Prüfliste (Definition of Done)
|
||||
- [ ] CI grün (Backend/Docs)
|
||||
- [x] Doku-Front‑Matter vorhanden und valide (`modul`, `status`, `summary`, optional `last_reviewed`, `review_cycle`, `yt_epic/yt_issues`)
|
||||
- [ ] Links geprüft (CI Link‑Checker läuft grün)
|
||||
- [ ] Falls relevant: YouTrack‑Key im PR‑Titel/Commit enthalten (z. B. MP-7)
|
||||
- [ ] Bei Architekturänderung: ADR aktualisiert und verlinkt
|
||||
|
||||
## Screenshots/Notizen (optional)
|
||||
-
|
||||
@@ -1,124 +0,0 @@
|
||||
name: CI - Main Pipeline
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-main-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
# ========================================
|
||||
# 1. Docker SSoT (bleibt wie ist)
|
||||
# ========================================
|
||||
docker-ssot:
|
||||
name: Docker SSoT Validation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Validate Docker SSoT (compat mode)
|
||||
if: ${{ hashFiles('scripts/docker-versions-update.sh') != '' && hashFiles('scripts/generate-compose-files.sh') != '' && hashFiles('scripts/validate-docker-consistency.sh') != '' }}
|
||||
run: |
|
||||
bash scripts/docker-versions-update.sh sync
|
||||
bash scripts/generate-compose-files.sh all development
|
||||
bash scripts/validate-docker-consistency.sh all
|
||||
|
||||
- name: Validate Docker SSoT (envless mode)
|
||||
if: ${{ hashFiles('scripts/generate-compose-files.sh') != '' && hashFiles('scripts/validate-docker-consistency.sh') != '' }}
|
||||
run: |
|
||||
DOCKER_SSOT_MODE=envless bash scripts/generate-compose-files.sh all development
|
||||
DOCKER_SSOT_MODE=envless bash scripts/validate-docker-consistency.sh all
|
||||
|
||||
- name: Lint docker-compose (fallback)
|
||||
if: ${{ hashFiles('scripts/validate-docker-consistency.sh') == '' }}
|
||||
run: docker compose -f docker/docker-compose.yaml config
|
||||
|
||||
# ========================================
|
||||
# 2. OpenAPI Validation (nur Lint)
|
||||
# ========================================
|
||||
validate-openapi:
|
||||
name: Validate OpenAPI Specs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install Spectral
|
||||
run: npm install -g @stoplight/spectral-cli
|
||||
|
||||
- name: Validate OpenAPI
|
||||
run: |
|
||||
spectral lint backend/gateway/src/main/resources/openapi/documentation.yaml \
|
||||
--ruleset .spectral.yaml \
|
||||
--fail-severity error
|
||||
|
||||
# ========================================
|
||||
# 3. Markdown Docs (nur kritische)
|
||||
# ========================================
|
||||
validate-docs:
|
||||
name: Validate Essential Docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Lint Critical Markdown
|
||||
uses: DavidAnson/markdownlint-cli2-action@v21
|
||||
with:
|
||||
globs: |
|
||||
README.md
|
||||
docs/README.md
|
||||
docs/01_Architecture/adr/**/*.md
|
||||
docs/02_Onboarding/Development/start-local.md
|
||||
|
||||
- name: Check Links in ADRs
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
config-file: '.github/markdown-link-check.json'
|
||||
folder-path: 'docs/01_Architecture/adr/'
|
||||
use-quiet-mode: 'yes'
|
||||
|
||||
# ========================================
|
||||
# 4. Build & Test
|
||||
# ========================================
|
||||
build-test:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ docker-ssot, validate-openapi, validate-docs ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew staticAnalysis build -x test
|
||||
|
||||
- name: Check Bundle Budgets
|
||||
run: ./gradlew checkBundleBudget
|
||||
|
||||
- name: Test
|
||||
run: ./gradlew test
|
||||
|
||||
- name: Upload OpenAPI Specs (Artifact)
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: openapi-specs
|
||||
path: docs/api/generated/*.json
|
||||
retention-days: 30
|
||||
continue-on-error: true
|
||||
@@ -1,196 +0,0 @@
|
||||
# ===================================================================
|
||||
# GitHub Actions - Automatisches Deployment auf Proxmox-Server
|
||||
# Meldestelle Project - CI/CD Pipeline
|
||||
# ===================================================================
|
||||
|
||||
name: Deploy Proxmox (manual)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-proxmox-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manueller Trigger
|
||||
|
||||
env:
|
||||
DOCKER_COMPOSE_VERSION: "v2.20.0"
|
||||
|
||||
jobs:
|
||||
# ===================================================================
|
||||
# Build & Test
|
||||
# ===================================================================
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Static Analysis
|
||||
run: ./gradlew staticAnalysis --no-daemon || true
|
||||
|
||||
- name: Build (all)
|
||||
run: ./gradlew build -x test --no-daemon
|
||||
|
||||
- name: Test (all)
|
||||
run: ./gradlew test --no-daemon || true # Allow failure for now
|
||||
|
||||
# ===================================================================
|
||||
# Deploy to Proxmox (nur bei main branch)
|
||||
# ===================================================================
|
||||
deploy:
|
||||
needs: build-and-test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup SSH Key
|
||||
uses: webfactory/ssh-agent@v0.8.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.PROXMOX_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Add Proxmox to known_hosts
|
||||
run: |
|
||||
ssh-keyscan -H ${{ secrets.PROXMOX_HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy to Proxmox Server
|
||||
env:
|
||||
PROXMOX_HOST: ${{ secrets.PROXMOX_HOST }}
|
||||
PROXMOX_USER: ${{ secrets.PROXMOX_USER }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
run: |
|
||||
ssh $PROXMOX_USER@$PROXMOX_HOST << 'ENDSSH'
|
||||
set -e
|
||||
|
||||
# Navigate to deployment directory
|
||||
cd ${{ secrets.DEPLOY_PATH }}
|
||||
|
||||
# Pull latest changes
|
||||
echo "🔄 Pulling latest changes from GitHub..."
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
|
||||
# Create backup of current environment
|
||||
echo "💾 Creating backup..."
|
||||
cp .env .env.backup.$(date +%Y%m%d_%H%M%S) || true
|
||||
|
||||
# Stop existing services
|
||||
echo "🛑 Stopping existing services..."
|
||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml down || true
|
||||
|
||||
# Clean up old images (optional)
|
||||
echo "🧹 Cleaning up old images..."
|
||||
docker image prune -f || true
|
||||
|
||||
# Build new images
|
||||
echo "🏗️ Building new images..."
|
||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml build
|
||||
|
||||
# Start infrastructure first
|
||||
echo "🚀 Starting infrastructure..."
|
||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml up -d
|
||||
|
||||
# Wait for infrastructure to be ready
|
||||
echo "⏳ Waiting for infrastructure..."
|
||||
sleep 30
|
||||
|
||||
# Start services
|
||||
echo "🚀 Starting services..."
|
||||
# Start services (already included in main compose file)
|
||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml up -d
|
||||
|
||||
# Wait for services to be ready
|
||||
echo "⏳ Waiting for services..."
|
||||
sleep 30
|
||||
|
||||
# Start clients
|
||||
echo "🚀 Starting clients..."
|
||||
# Start clients (already included in main compose file)
|
||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml up -d
|
||||
|
||||
# Health check
|
||||
echo "🏥 Running health checks..."
|
||||
sleep 60
|
||||
|
||||
# Check service status
|
||||
echo "📊 Service Status:"
|
||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml ps
|
||||
|
||||
# Check logs for errors
|
||||
echo "📋 Recent logs:"
|
||||
docker compose --env-file docker/.env -f docker/docker-compose.yaml logs --tail=50
|
||||
|
||||
echo "✅ Deployment completed successfully!"
|
||||
ENDSSH
|
||||
|
||||
- name: Verify Deployment
|
||||
env:
|
||||
PROXMOX_HOST: ${{ secrets.PROXMOX_HOST }}
|
||||
PROXMOX_USER: ${{ secrets.PROXMOX_USER }}
|
||||
run: |
|
||||
echo "🔍 Verifying deployment..."
|
||||
|
||||
# Check if services are responding
|
||||
ssh $PROXMOX_USER@$PROXMOX_HOST << 'ENDSSH'
|
||||
# Check API Gateway health
|
||||
curl -f http://localhost:8081/actuator/health || echo "❌ API Gateway health check failed"
|
||||
|
||||
# Check Consul
|
||||
curl -f http://localhost:8500/v1/status/leader || echo "❌ Consul health check failed"
|
||||
|
||||
# Check Web-App
|
||||
curl -f http://localhost:4000/health || echo "❌ Web-App health check failed"
|
||||
|
||||
# Check VNC
|
||||
curl -f http://localhost:6080/ || echo "❌ VNC health check failed"
|
||||
|
||||
echo "✅ Health checks completed"
|
||||
ENDSSH
|
||||
|
||||
# ===================================================================
|
||||
# Notification (Optional)
|
||||
# ===================================================================
|
||||
notify:
|
||||
needs: [build-and-test, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Notify Success
|
||||
if: needs.deploy.result == 'success'
|
||||
run: |
|
||||
echo "✅ Deployment to Proxmox successful!"
|
||||
echo "🌐 Web-App: https://meldestelle.yourdomain.com"
|
||||
echo "🖥️ Desktop-VNC: https://vnc.meldestelle.yourdomain.com"
|
||||
echo "🔗 API: https://api.meldestelle.yourdomain.com"
|
||||
|
||||
- name: Notify Failure
|
||||
if: needs.deploy.result == 'failure'
|
||||
run: |
|
||||
echo "❌ Deployment to Proxmox failed!"
|
||||
echo "Check the logs above for details."
|
||||
@@ -1,57 +0,0 @@
|
||||
name: KDoc → YouTrack KB Sync
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: kdoc-sync-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
kb_root_title:
|
||||
description: "Root-Titel der YouTrack KB (z. B. 'API & Entwicklerdoku')"
|
||||
required: true
|
||||
default: "API & Entwicklerdoku"
|
||||
bc_root:
|
||||
description: "Ordner unterhalb des KB-Roots für BCs (z. B. 'BCs')"
|
||||
required: false
|
||||
default: "BCs"
|
||||
|
||||
jobs:
|
||||
kdoc-sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Build Dokka (GFM)
|
||||
run: |
|
||||
./gradlew --no-daemon dokkaGfmAll || ./gradlew --no-daemon dokkaGfm
|
||||
|
||||
- name: Python deps for YouTrack sync
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests pyyaml
|
||||
|
||||
- name: Sync KDoc Markdown to YouTrack KB
|
||||
if: ${{ hashFiles('.junie/scripts/youtrack-sync-kb.py') != '' }}
|
||||
env:
|
||||
YT_URL: ${{ secrets.YT_URL }}
|
||||
YT_TOKEN: ${{ secrets.YT_TOKEN }}
|
||||
KB_ROOT_TITLE: ${{ github.event.inputs.kb_root_title }}
|
||||
KB_BC_ROOT: ${{ github.event.inputs.bc_root }}
|
||||
run: |
|
||||
python .junie/scripts/youtrack-sync-kb.py --src build/dokka/gfm
|
||||
@@ -1,229 +0,0 @@
|
||||
name: Integration Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: integration-tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
keycloak_db: [postgres, dev-file]
|
||||
env:
|
||||
KEYCLOAK_VERSION: "26.4.2"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: meldestelle
|
||||
POSTGRES_PASSWORD: meldestelle
|
||||
POSTGRES_DB: meldestelle
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U meldestelle -d $${POSTGRES_DB}"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
--health-start-period 20s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
|
||||
zookeeper:
|
||||
image: confluentinc/cp-zookeeper:7.5.0
|
||||
env:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ports:
|
||||
- 2181:2181
|
||||
options: >-
|
||||
--health-cmd "nc -z localhost 2181 || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 3
|
||||
--health-start-period 10s
|
||||
|
||||
kafka:
|
||||
image: confluentinc/cp-kafka:7.5.0
|
||||
env:
|
||||
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
|
||||
ports:
|
||||
- 9092:9092
|
||||
options: >-
|
||||
--health-cmd "kafka-topics --bootstrap-server localhost:9092 --list || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 3
|
||||
--health-start-period 30s
|
||||
|
||||
zipkin:
|
||||
image: openzipkin/zipkin:2
|
||||
ports:
|
||||
- 9411:9411
|
||||
options: >-
|
||||
--health-cmd "wget -q -O - http://localhost:9411/health || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 3
|
||||
--health-start-period 10s
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: 'temurin'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Setup Gradle (modern)
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Wait for Postgres to be ready (pg_isready in service network)
|
||||
if: ${{ matrix.keycloak_db == 'postgres' }}
|
||||
run: |
|
||||
echo "Waiting for Postgres..."
|
||||
for i in {1..40}; do
|
||||
if docker run --rm --network ${{ job.services.postgres.network }} \
|
||||
postgres:16-alpine pg_isready -h postgres -p 5432 -U meldestelle -d meldestelle; then
|
||||
echo "Postgres is ready"; break; fi; echo -n "."; sleep 3; done
|
||||
|
||||
- name: Start Keycloak with Postgres (dev) and wait for readiness
|
||||
if: ${{ matrix.keycloak_db == 'postgres' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Starting Keycloak (DB=postgres)..."
|
||||
docker run -d --name keycloak \
|
||||
--network ${{ job.services.postgres.network }} \
|
||||
-p 8180:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
-e KC_DB=postgres \
|
||||
-e KC_DB_URL=jdbc:postgresql://postgres:5432/meldestelle \
|
||||
-e KC_DB_USERNAME=meldestelle \
|
||||
-e KC_DB_PASSWORD=meldestelle \
|
||||
-e KC_HEALTH_ENABLED=true \
|
||||
-e JAVA_OPTS="-Xms256m -Xmx1024m -XX:MaxRAMPercentage=60" \
|
||||
quay.io/keycloak/keycloak:${{ env.KEYCLOAK_VERSION }} \
|
||||
start-dev
|
||||
|
||||
echo "Giving Keycloak 30s to initialize..."; sleep 30
|
||||
|
||||
wait_for() {
|
||||
local url="$1"; local label="$2"; local timeout="${3:-180}"; local interval="${4:-5}"
|
||||
echo "Waiting for $label ($url) ..."
|
||||
if ! timeout ${timeout}s bash -c 'until curl -fsS --output /dev/null '"$url"'; do echo -n "."; sleep '"$interval"'; done'; then
|
||||
echo "\n[WAIT] Timeout on $url"
|
||||
return 1
|
||||
fi
|
||||
echo "\n[WAIT] $label is up"
|
||||
}
|
||||
|
||||
if ! wait_for http://localhost:8180/ "root" 180 5; then
|
||||
docker logs --tail=200 keycloak || true
|
||||
exit 1
|
||||
fi
|
||||
if ! wait_for http://localhost:8180/health "health" 180 5; then
|
||||
echo "[INFO] /health nicht erreichbar – versuche /q/health (Quarkus default)"
|
||||
wait_for http://localhost:8180/q/health "q-health" 180 5 || true
|
||||
fi
|
||||
wait_for http://localhost:8180/health/ready "health-ready" 300 5 || true
|
||||
wait_for http://localhost:8180/admin/master/console/ "admin-console" 300 5 || (docker logs --tail=400 keycloak && exit 1)
|
||||
|
||||
- name: Start Keycloak with dev-file (no Postgres) and wait for readiness
|
||||
if: ${{ matrix.keycloak_db == 'dev-file' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Starting Keycloak (DB=dev-file, no Postgres)..."
|
||||
docker run -d --name keycloak \
|
||||
-p 8180:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
-e KC_DB=dev-file \
|
||||
-e KC_HEALTH_ENABLED=true \
|
||||
-e JAVA_OPTS="-Xms256m -Xmx1024m -XX:MaxRAMPercentage=60" \
|
||||
quay.io/keycloak/keycloak:${{ env.KEYCLOAK_VERSION }} \
|
||||
start-dev
|
||||
|
||||
echo "Giving Keycloak 20s to initialize..."; sleep 20
|
||||
|
||||
wait_for() {
|
||||
local url="$1"; local label="$2"; local timeout="${3:-180}"; local interval="${4:-5}"
|
||||
echo "Waiting for $label ($url) ..."
|
||||
if ! timeout ${timeout}s bash -c 'until curl -fsS --output /dev/null '"$url"'; do echo -n "."; sleep '"$interval"'; done'; then
|
||||
echo "\n[WAIT] Timeout on $url"
|
||||
return 1
|
||||
fi
|
||||
echo "\n[WAIT] $label is up"
|
||||
}
|
||||
|
||||
if ! wait_for http://localhost:8180/ "root" 180 5; then
|
||||
docker logs --tail=200 keycloak || true
|
||||
exit 1
|
||||
fi
|
||||
if ! wait_for http://localhost:8180/health "health" 180 5; then
|
||||
echo "[INFO] /health nicht erreichbar – versuche /q/health (Quarkus default)"
|
||||
wait_for http://localhost:8180/q/health "q-health" 180 5 || true
|
||||
fi
|
||||
wait_for http://localhost:8180/health/ready "health-ready" 300 5 || true
|
||||
wait_for http://localhost:8180/admin/master/console/ "admin-console" 300 5 || (docker logs --tail=400 keycloak && exit 1)
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Static Analysis
|
||||
run: ./gradlew staticAnalysis --no-daemon
|
||||
|
||||
- name: Run integration tests
|
||||
run: ./gradlew integrationTest --no-daemon --parallel
|
||||
env:
|
||||
# Environment variables for Redis connection
|
||||
REDIS_HOST: localhost
|
||||
REDIS_PORT: 6379
|
||||
# Keycloak base URL for integration tests (manual container)
|
||||
KEYCLOAK_AUTH_SERVER_URL: http://localhost:8180
|
||||
# Spring profile for integration tests
|
||||
SPRING_PROFILES_ACTIVE: integration-test
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@v5
|
||||
if: always()
|
||||
with:
|
||||
name: integration-test-reports
|
||||
path: |
|
||||
**/build/reports/tests/integrationTest/
|
||||
**/build/test-results/integrationTest/
|
||||
retention-days: 7
|
||||
|
||||
- name: Dump service logs (Keycloak, Postgres)
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== Docker ps ===" && docker ps -a || true
|
||||
echo "=== Keycloak logs (tail) ===" && docker logs --tail=400 keycloak || true
|
||||
echo "=== Postgres logs (tail) ===" && docker logs --tail=200 $(docker ps -a --filter "name=postgres" --format "{{.ID}}") || true
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Docker SSoT Guard (Minimal)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'docker/**'
|
||||
- '.github/workflows/ssot-guard.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docker/**'
|
||||
- '.github/workflows/ssot-guard.yml'
|
||||
|
||||
jobs:
|
||||
check-compose-config:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# FIX: .env aus Example erstellen, damit Variablen da sind
|
||||
- name: Create .env context
|
||||
run: cp docker/.env.example docker/.env
|
||||
|
||||
# FIX: .yaml Extension nutzen (SSoT)
|
||||
- name: Validate Docker Compose Config
|
||||
run: docker compose --env-file docker/.env -f docker/docker-compose.yaml config
|
||||
@@ -1,59 +0,0 @@
|
||||
name: YouTrack Sync (on merge)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: youtrack-sync-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Secrets als Umgebungsvariablen für den Job verfügbar machen
|
||||
env:
|
||||
YT_URL: ${{ secrets.YT_URL }}
|
||||
YT_TOKEN: ${{ secrets.YT_TOKEN }}
|
||||
|
||||
steps:
|
||||
# WICHTIG: Checkout ist notwendig, damit "git log" funktioniert
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Notwendig, um die Commit-Historie für "git log" zu laden
|
||||
|
||||
- name: Comment to YouTrack Issue(s)
|
||||
# Wir haben das problematische "if: ${{ secrets... }}" entfernt.
|
||||
# Die Prüfung findet nun sicher im Shell-Skript statt.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Prüfen, ob Secrets vorhanden sind (Shell-Ebene)
|
||||
if [ -z "${YT_URL:-}" ] || [ -z "${YT_TOKEN:-}" ]; then
|
||||
echo "Skipping: YT_URL or YT_TOKEN is not set."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Commit Message nach Issue-Keys durchsuchen
|
||||
KEYS=$(git log -1 --pretty=%B | grep -o '[A-Z]\+-[0-9]\+' | sort -u || true)
|
||||
if [ -z "$KEYS" ]; then
|
||||
echo "No issue keys in last commit message. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3. Kommentar an YouTrack senden
|
||||
for ISSUE in $KEYS; do
|
||||
MSG=$(printf 'PR/Commit gemergt: %s\nRepo: %s\nCommit: %s' "${{ github.event.head_commit.url }}" "${{ github.repository }}" "${{ github.sha }}")
|
||||
echo "Posting comment to $ISSUE..."
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $YT_TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$YT_URL/api/issues/$ISSUE/comments" \
|
||||
-d "{\"text\": \"$MSG\"}"
|
||||
done
|
||||
@@ -5,8 +5,8 @@
|
||||
# ===================================================================
|
||||
|
||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||
ARG GRADLE_VERSION
|
||||
ARG JAVA_VERSION
|
||||
ARG GRADLE_VERSION=9.3.1
|
||||
ARG JAVA_VERSION=25
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ dependencies {
|
||||
implementation(libs.spring.cloud.starter.circuitbreaker.reactor.resilience4j)
|
||||
|
||||
implementation(libs.spring.data.valkey)
|
||||
implementation(libs.micrometer.tracing.bridge.brave)
|
||||
// micrometer-tracing-bridge-brave + zipkin-reporter-brave + zipkin-sender-okhttp3
|
||||
// werden transitiv via :backend:infrastructure:monitoring:monitoringClient bereitgestellt.
|
||||
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ spring:
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- "org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration"
|
||||
# --- VALKEY (für Rate Limiting) ---
|
||||
# --- VALKEY (Redis-protokollkompatibel, Spring nutzt spring.data.redis.*) ---
|
||||
data:
|
||||
valkey:
|
||||
redis:
|
||||
host: ${SPRING_DATA_VALKEY_HOST:localhost}
|
||||
port: ${SPRING_DATA_VALKEY_PORT:6379}
|
||||
password: ${SPRING_DATA_VALKEY_PASSWORD:valkey-password}
|
||||
password: ${SPRING_DATA_VALKEY_PASSWORD:}
|
||||
|
||||
# --- CONSUL (Service Discovery) ---
|
||||
cloud:
|
||||
@@ -52,9 +52,15 @@ management:
|
||||
enabled: true # Aktiviert /actuator/health/liveness und /readiness
|
||||
tracing:
|
||||
sampling:
|
||||
probability: 1.0
|
||||
probability: ${TRACING_SAMPLING_PROBABILITY:1.0}
|
||||
propagation:
|
||||
type: "w3c"
|
||||
# B3 ist das native Format von Brave/Zipkin. W3C wäre für OpenTelemetry.
|
||||
# Alle Services nutzen Brave (via monitoring-client), daher muss das Gateway ebenfalls B3 verwenden.
|
||||
type: "b3"
|
||||
zipkin:
|
||||
tracing:
|
||||
# Lokal: Zipkin auf Port 9411. In Docker via ENV MANAGEMENT_ZIPKIN_TRACING_ENDPOINT überschrieben.
|
||||
endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
|
||||
|
||||
# --- Custom Service URLs ---
|
||||
# Default: Localhost (für Entwicklung ohne Docker)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
# ===================================================================
|
||||
|
||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||
ARG GRADLE_VERSION
|
||||
ARG JAVA_VERSION
|
||||
ARG GRADLE_VERSION=9.3.1
|
||||
ARG JAVA_VERSION=25
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ spring:
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
# --- REDIS KONFIGURATION ---
|
||||
# --- VALKEY KONFIGURATION (Redis-protokollkompatibel, Spring nutzt spring.data.redis.*) ---
|
||||
data:
|
||||
redis:
|
||||
host: ${SPRING_DATA_REDIS_HOST:localhost}
|
||||
port: ${SPRING_DATA_REDIS_PORT:6379}
|
||||
password: ${SPRING_DATA_REDIS_PASSWORD:redis-password}
|
||||
host: ${SPRING_DATA_VALKEY_HOST:localhost}
|
||||
port: ${SPRING_DATA_VALKEY_PORT:6379}
|
||||
password: ${SPRING_DATA_VALKEY_PASSWORD:}
|
||||
connect-timeout: 5s
|
||||
timeout: 2s
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# ===================================================================
|
||||
# Production-Ready Keycloak Dockerfile
|
||||
# ===================================================================
|
||||
# Based on: quay.io/keycloak/keycloak:26.4
|
||||
# Based on: quay.io/keycloak/keycloak:26.5.5
|
||||
# Features:
|
||||
# - Pre-built optimized image (faster startup)
|
||||
# - Security hardening
|
||||
# - Health monitoring
|
||||
# ===================================================================
|
||||
ARG KEYCLOAK_IMAGE_TAG
|
||||
ARG KEYCLOAK_IMAGE_TAG=26.5.5
|
||||
|
||||
FROM quay.io/keycloak/keycloak:${KEYCLOAK_IMAGE_TAG}
|
||||
|
||||
ARG KEYCLOAK_IMAGE_TAG=26.5.5
|
||||
|
||||
LABEL maintainer="Meldestelle Development Team"
|
||||
LABEL description="Production-ready Keycloak for Meldestelle authentication"
|
||||
LABEL version="${KEYCLOAK_IMAGE_TAG}"
|
||||
|
||||
@@ -93,8 +93,7 @@
|
||||
"http://localhost:8081",
|
||||
"http://localhost:3000",
|
||||
"https://app.meldestelle.at",
|
||||
"http://localhost:8080",
|
||||
"*"
|
||||
"http://localhost:8080"
|
||||
],
|
||||
"protocol": "openid-connect",
|
||||
"bearerOnly": false,
|
||||
@@ -111,7 +110,8 @@
|
||||
"client.secret.creation.time": "0",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"backchannel.logout.revoke.offline.tokens": "false"
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"post.logout.redirect.uris": "http://localhost:8081/*##http://localhost:3000/*##https://app.meldestelle.at/*"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
@@ -202,12 +202,12 @@
|
||||
"http://localhost:8080",
|
||||
"http://localhost:4000",
|
||||
"http://localhost:3000",
|
||||
"https://app.meldestelle.at",
|
||||
"*"
|
||||
"https://app.meldestelle.at"
|
||||
],
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"post.logout.redirect.uris": "http://localhost:8080/*##http://localhost:4000/*##http://localhost:3000/*##https://app.meldestelle.at/*"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -229,6 +229,47 @@
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"fullScopeAllowed": true
|
||||
},
|
||||
{
|
||||
"clientId": "frontend-client",
|
||||
"name": "Meldestelle Desktop & Mobile Client",
|
||||
"description": "Public PKCE client for the KMP Compose Desktop/Mobile application",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"authorizationServicesEnabled": false,
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"post.logout.redirect.uris": "meldestelle://callback##http://localhost:*##https://app.meldestelle.at/*"
|
||||
},
|
||||
"redirectUris": [
|
||||
"meldestelle://callback",
|
||||
"http://localhost:*",
|
||||
"https://app.meldestelle.at/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:8080",
|
||||
"http://localhost:4000",
|
||||
"https://app.meldestelle.at"
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"basic",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
@@ -242,7 +283,7 @@
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password",
|
||||
"value": "Admin#1234",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
@@ -266,7 +307,7 @@
|
||||
"requiredCredentials": [
|
||||
"password"
|
||||
],
|
||||
"passwordPolicy": "length(8)",
|
||||
"passwordPolicy": "length(10) and digits(1) and upperCase(1) and specialChars(1) and notUsername(undefined)",
|
||||
"otpPolicyType": "totp",
|
||||
"otpPolicyAlgorithm": "HmacSHA1",
|
||||
"otpPolicyInitialCounter": 0,
|
||||
|
||||
+12
-8
@@ -76,8 +76,8 @@ services:
|
||||
context: .
|
||||
dockerfile: config/docker/keycloak/Dockerfile
|
||||
args:
|
||||
KEYCLOAK_IMAGE_TAG: "${KEYCLOAK_IMAGE_TAG:-26.4}"
|
||||
image: "${DOCKER_REGISTRY:-git.mo-code.at/mocode-software}/keycloak:${KEYCLOAK_IMAGE_TAG:-26.4}"
|
||||
KEYCLOAK_IMAGE_TAG: "${KEYCLOAK_IMAGE_TAG:-26.5.5}"
|
||||
image: "${DOCKER_REGISTRY:-git.mo-code.at/mocode-software}/keycloak:${KEYCLOAK_IMAGE_TAG:-26.5.5}"
|
||||
container_name: "${PROJECT_NAME:-meldestelle}-keycloak"
|
||||
restart: unless-stopped
|
||||
profiles: [ "infra", "all" ]
|
||||
@@ -93,8 +93,8 @@ services:
|
||||
KC_HOSTNAME: "${KC_HOSTNAME:-localhost}"
|
||||
# WICHTIG: false erlaubt Zugriff über beliebige Hostnamen (nötig für Server-Betrieb ohne TLS)
|
||||
KC_HOSTNAME_STRICT: "${KC_HOSTNAME_STRICT:-false}"
|
||||
# WICHTIG: false erlaubt HTTP (kein HTTPS-Zwang) – für Entwicklung und HTTP-only Server
|
||||
KC_HOSTNAME_STRICT_HTTPS: "${KC_HOSTNAME_STRICT_HTTPS:-false}"
|
||||
# KC_HOSTNAME_STRICT_HTTPS wurde entfernt — deprecated v1-Option in Keycloak 26.x (hostname v2).
|
||||
# HTTP-Zugriff wird ausschließlich über KC_HTTP_ENABLED gesteuert.
|
||||
KC_HTTP_ENABLED: "true"
|
||||
# Admin-Interface explizit auf allen Interfaces binden (0.0.0.0)
|
||||
KC_HTTP_MANAGEMENT_PORT: "9000"
|
||||
@@ -109,11 +109,15 @@ services:
|
||||
postgres:
|
||||
condition: "service_healthy"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "curl -sf http://localhost:9000/health/ready || exit 1" ]
|
||||
# Keycloak basiert auf ubi9-micro — curl/wget sind NICHT im Image enthalten!
|
||||
# Lösung: Bash /dev/tcp — kein externes Tool nötig, funktioniert auf jedem bash-Image.
|
||||
# Management-Port 9000: Health-Endpoints (/health/live, /health/ready) laufen hier.
|
||||
# /health/live: prüft nur Prozess-Liveness — kein Warten auf JGroups-Cluster-Formation.
|
||||
test: [ "CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && printf 'GET /health/live HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"UP\"'" ]
|
||||
interval: "15s"
|
||||
timeout: "5s"
|
||||
retries: "10"
|
||||
start_period: "60s"
|
||||
timeout: "10s"
|
||||
retries: 5
|
||||
start_period: "90s"
|
||||
volumes:
|
||||
- "./config/docker/keycloak:/opt/keycloak/data/import:Z"
|
||||
# start --optimized nutzt das pre-built Image (kc.sh build im Dockerfile)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: Roadmap
|
||||
status: ACTIVE
|
||||
owner: Lead Architect
|
||||
last_update: 2026-02-07
|
||||
last_update: 2026-03-09 (Ping Service Tracer Bullet abgeschlossen)
|
||||
---
|
||||
|
||||
# MASTER ROADMAP Q1 2026: "Operation Tracer Bullet"
|
||||
@@ -31,7 +31,9 @@ Wir validieren die gesamte Architektur-Kette (Frontend -> Gateway -> Service ->
|
||||
* [x] **Persistence:** Flyway & Postgres Integration stabil.
|
||||
|
||||
#### 🏗️ Agent: Infrastructure & DevOps
|
||||
* [x] **Docker Environment:** `dc-infra`, `dc-backend`, `dc-gui` stabil. Valkey als Redis-Ersatz integriert.
|
||||
|
||||
* [x] **Docker Environment:** `dc-infra`, `dc-backend`, `dc-gui` stabil. Valkey als Redis-Ersatz integriert. _(
|
||||
verifiziert 2026-03-09: KC_COMMAND-Regression + Valkey/Redis Property-Mismatch behoben)_
|
||||
* [x] **Gateway Config:** Routing `/api/ping/**` -> `ping-service` mit CircuitBreaker Fallback konfiguriert.
|
||||
|
||||
---
|
||||
@@ -43,6 +45,8 @@ Wir validieren die gesamte Architektur-Kette (Frontend -> Gateway -> Service ->
|
||||
* [x] **HTTP Client Core:** Ktor Client mit Auth & Error Handling.
|
||||
* [x] **Authentication Flow:** OIDC Login Flow (Keycloak) implementiert.
|
||||
* [x] **UI Implementation:** Debug-Screen für Pings vorhanden.
|
||||
* [x] **Sync-Fix:** `PingApiKoinClient.syncPings()` Query-Parameter `lastSyncTimestamp` → `since` korrigiert. _(
|
||||
verifiziert 2026-03-09)_
|
||||
|
||||
---
|
||||
|
||||
@@ -64,8 +68,11 @@ Wir validieren die gesamte Architektur-Kette (Frontend -> Gateway -> Service ->
|
||||
5. [x] In Zipkin ist der komplette Request-Trace (Frontend -> Gateway -> Service -> DB) sichtbar.
|
||||
|
||||
## 4. Next Steps (Q1/2026)
|
||||
1. **Entries Service:** Beginn der Implementierung des ersten echten Fach-Services ("Nennungen").
|
||||
2. **System Hardening:** Keycloak Production-Config (kein `start-dev`).
|
||||
|
||||
1. **Entries Service:** Beginn der Implementierung des ersten echten Fach-Services ("Nennungen"). ← **NÄCHSTE PRIORITÄT
|
||||
**
|
||||
2. ~~**System Hardening:** Keycloak Production-Config (kein `start-dev`).~~ ✅ _(erledigt 2026-03-09: `.env` KC_COMMAND
|
||||
auf `start --optimized --import-realm` korrigiert)_
|
||||
3. **Reporting / Printing:** (Vorgemerkt)
|
||||
* Anforderung: PDF-Generierung für Startlisten, Ergebnislisten, Dressur-Protokolle (personalisiert).
|
||||
* Architektur-Entscheidung: Dezentraler Microservice (wegen Resource-Bursts).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: Roadmap
|
||||
status: ARCHIVED
|
||||
owner: Lead Architect
|
||||
last_update: 2026-01-15
|
||||
last_update: 2026-03-09
|
||||
---
|
||||
|
||||
# Roadmap Q1 2026: "Operation Tracer Bullet"
|
||||
@@ -20,15 +20,22 @@ last_update: 2026-01-15
|
||||
Das Ziel dieser Phase ist es, die Entwicklungsumgebung (Build, Docker, Dependencies) so zu stabilisieren, dass alle Entwickler (und Agenten) reibungslos arbeiten können.
|
||||
|
||||
### 1.1 Build-System & Dependencies (Architect)
|
||||
- [ ] **Spring Cloud Fix:** Downgrade von `2025.1.0` (Oakwood) auf `2025.0.1` (Northfields) in `libs.versions.toml` und `platform-bom`.
|
||||
- [ ] **Wasm Build Fix:** Analyse und Behebung der `Worker` / `Unresolved reference` Fehler im Frontend-Build. Ggf. explizite Dependency für `kotlinx-browser` prüfen.
|
||||
- [ ] **Dependency Cleanup:** Entfernen von redundanten Datenbank-Libs (Entscheidung: SQLDelight für KMP Client, Room entfernen wenn nicht genutzt; Exposed im Backend auf `1.0.0-rc-4` heben).
|
||||
- [ ] **Micrometer Upgrade:** Explizites Setzen von Micrometer `1.16.1` für besseren Java 25 Support.
|
||||
- [x] **Spring Cloud Fix:** Downgrade von `2025.1.0` (Oakwood) auf `2025.0.1` (Northfields) in `libs.versions.toml` und `platform-bom`. _(verifiziert 2026-03-09)_
|
||||
- [ ] **Wasm Build Fix:** Analyse und Behebung der `Worker` / `Unresolved reference` Fehler im Frontend-Build. Ggf. explizite Dependency für `kotlinx-browser` prüfen. _(zurückgestellt — erst nach DevOps-Stabilisierung)_
|
||||
- [x] **Dependency Cleanup:** Entfernen von redundanten Datenbank-Libs (Entscheidung: SQLDelight für KMP Client, Room entfernen wenn nicht genutzt; Exposed im Backend auf `1.0.0-rc-4` heben). _(verifiziert 2026-03-09: Room entfernt, SQLDelight 2.2.1 aktiv)_
|
||||
- [x] **Micrometer Upgrade:** Explizites Setzen von Micrometer `1.16.1` für besseren Java 25 Support. _(verifiziert
|
||||
2026-03-09: micrometer = "1.16.1" bereits in libs.versions.toml gesetzt)_
|
||||
|
||||
### 1.2 Infrastruktur & Docker (DevOps)
|
||||
- [ ] **Gateway CircuitBreaker:** Behebung des `ClassNotFoundException` / `NoSuchMethodError` im Gateway (vermutlich Folge des Spring Cloud Konflikts).
|
||||
- [ ] **Docker Stabilität:** Sicherstellen, dass `docker compose up` zuverlässig alle Services (Consul, Keycloak, Postgres) startet und vernetzt.
|
||||
- [ ] **Keycloak Config:** Validierung der Realm-Konfiguration (`meldestelle`) und der Client-Scopes für den `ping-service`.
|
||||
|
||||
- [x] **Gateway CircuitBreaker:** Behebung des `ClassNotFoundException` / `NoSuchMethodError` im Gateway (vermutlich
|
||||
Folge des Spring Cloud Konflikts). _(verifiziert 2026-03-09: Spring Cloud 2025.0.1 + reactor-resilience4j korrekt;
|
||||
GatewayConfig.kt + FallbackController vorhanden)_
|
||||
- [x] **Docker Stabilität:** Sicherstellen, dass `docker compose up` zuverlässig alle Services (Consul, Keycloak,
|
||||
Postgres) startet und vernetzt. _(verifiziert 2026-03-09: 3 Bugs behoben — KC_COMMAND, Valkey Env-Vars, Gateway
|
||||
Property-Namespace)_
|
||||
- [x] **Keycloak Config:** Validierung der Realm-Konfiguration (`meldestelle`) und der Client-Scopes für den
|
||||
`ping-service`. _(verifiziert 2026-03-09: frontend-client + PKCE + CORS-Härtung + Password-Policy umgesetzt)_
|
||||
|
||||
---
|
||||
|
||||
@@ -37,14 +44,19 @@ Das Ziel dieser Phase ist es, die Entwicklungsumgebung (Build, Docker, Dependenc
|
||||
Implementierung des ersten vertikalen Durchstichs.
|
||||
|
||||
### 2.1 Modul-Struktur & API (Backend Dev)
|
||||
- [ ] **Refactoring `core-utils`:** Verschieben von JVM-spezifischem Code (`DatabaseUtils`) nach `:backend:infrastructure:persistence`. `core-utils` muss "pure KMP" sein.
|
||||
- [ ] **API Definition:** Erstellung von `:contracts:ping-api` mit KMP-kompatiblen DTOs (`PingResponse`).
|
||||
- [x] **Refactoring `core-utils`:** Verschieben von JVM-spezifischem Code (`DatabaseUtils`) nach `:backend:infrastructure:persistence`. `core-utils` muss "pure KMP" sein. _(verifiziert 2026-03-09: Stub-Datei mit Redirect vorhanden, Code in persistence)_
|
||||
- [x] **API Definition:** Erstellung von `:contracts:ping-api` mit KMP-kompatiblen DTOs (`PingResponse`). _(verifiziert
|
||||
2026-03-09)_
|
||||
|
||||
### 2.2 Service Implementation (Backend Dev)
|
||||
- [ ] **Ping Service:** Implementierung von `:backend:services:ping:ping-service` mit Spring Boot 3.5.9.
|
||||
- [ ] **Security:** Integration von OAuth2 Resource Server (Keycloak) und Absicherung des `/secure` Endpoints.
|
||||
- [ ] **Discovery:** Registrierung bei Consul.
|
||||
- [ ] **Observability:** Tracing mit Zipkin und Metrics mit Prometheus aktivieren.
|
||||
|
||||
- [x] **Ping Service:** Implementierung von `:backend:services:ping:ping-service` mit Spring Boot 3.5.9. _(verifiziert
|
||||
2026-03-09)_
|
||||
- [x] **Security:** Integration von OAuth2 Resource Server (Keycloak) und Absicherung des `/secure` Endpoints. _(
|
||||
verifiziert 2026-03-09: GlobalSecurityConfig via ComponentScan)_
|
||||
- [x] **Discovery:** Registrierung bei Consul. _(verifiziert 2026-03-09: application.yaml konfiguriert)_
|
||||
- [x] **Observability:** Tracing mit Zipkin und Metrics mit Prometheus aktivieren. _(verifiziert 2026-03-09:
|
||||
monitoring-client Bundle + Gateway B3-Fix)_
|
||||
|
||||
---
|
||||
|
||||
@@ -53,20 +65,24 @@ Implementierung des ersten vertikalen Durchstichs.
|
||||
Anbindung des Frontends an den neuen Service.
|
||||
|
||||
### 3.1 HTTP Client & Sync (Frontend Expert)
|
||||
- [ ] **Ktor Client:** Konfiguration des HTTP-Clients für die Kommunikation mit dem Gateway (`http://localhost:8080`).
|
||||
- [ ] **Auth:** Implementierung des OIDC-Flows im Frontend (Login via Keycloak), Speichern des Tokens.
|
||||
- [ ] **Integration:** Aufruf von `/api/ping` und `/api/ping/secure` und Anzeige im UI.
|
||||
|
||||
- [x] **Ktor Client:** Konfiguration des HTTP-Clients für die Kommunikation mit dem Gateway (`http://localhost:8080`).
|
||||
_(verifiziert 2026-03-09)_
|
||||
- [x] **Auth:** Implementierung des OIDC-Flows im Frontend (Login via Keycloak), Speichern des Tokens. _(verifiziert
|
||||
2026-03-09: PKCE S256 + frontend-client + JVM/JS actual-Implementierungen)_
|
||||
- [x] **Integration:** Aufruf von `/api/ping` und `/api/ping/secure` und Anzeige im UI. _(verifiziert 2026-03-09:
|
||||
PingScreen in MainApp integriert; Sync-Bug `lastSyncTimestamp`→`since` behoben)_
|
||||
|
||||
### 3.2 Offline-Sync Basis (Frontend Expert)
|
||||
- [ ] **Sync-Logik:** Erste Implementierung eines Sync-Mechanismus (z.B. Queue für Offline-Requests) basierend auf SQLDelight.
|
||||
- [x] **Sync-Logik:** Erste Implementierung eines Sync-Mechanismus (z.B. Queue für Offline-Requests) basierend auf SQLDelight. _(verifiziert 2026-03-09: frontend/core/local-db vollständig implementiert)_
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Review & Hardening (Woche 4)
|
||||
|
||||
### 4.1 Quality Assurance (QA Specialist)
|
||||
- [ ] **Integrationstests:** Automatisierte Tests, die den gesamten Flow (Frontend -> Gateway -> Service) prüfen.
|
||||
- [ ] **Lasttests:** Einfache Lasttests auf den `ping-service` zur Validierung der Virtual Threads.
|
||||
- [ ] **Integrationstests:** Automatisierte Tests, die den gesamten Flow (Frontend -> Gateway -> Service) prüfen. _(zurückgestellt — erst nach DevOps/CI-CD-Stabilisierung)_
|
||||
- [ ] **Lasttests:** Einfache Lasttests auf den `ping-service` zur Validierung der Virtual Threads. _(zurückgestellt — erst bei fachlicher Implementierung)_
|
||||
|
||||
### 4.2 Documentation (Curator)
|
||||
- [ ] **Update Docs:** Aktualisierung der Architektur-Dokumentation basierend auf den Erkenntnissen.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: Roadmap
|
||||
status: ARCHIVED
|
||||
owner: Lead Architect
|
||||
last_update: 2026-01-15
|
||||
last_update: 2026-03-09
|
||||
---
|
||||
|
||||
# Roadmap: System Hardening & Stability
|
||||
@@ -15,48 +15,73 @@ last_update: 2026-01-15
|
||||
## 1. Backend & Build System (Architect / Backend Dev)
|
||||
|
||||
### 1.1 Dependency Management
|
||||
- [ ] **Spring Cloud 2025.0.1 Downgrade:**
|
||||
- [x] **Spring Cloud 2025.0.1 Downgrade:**
|
||||
- `libs.versions.toml`: Spring Cloud Version auf `2025.0.1` setzen.
|
||||
- `platform/build.gradle.kts`: BOM Import prüfen.
|
||||
- Ziel: Behebung der `ClassNotFoundException` im Gateway (CircuitBreaker).
|
||||
- [ ] **Micrometer 1.16.1:**
|
||||
- _(verifiziert 2026-03-09)_
|
||||
- [x] **Gateway CircuitBreaker:** _(verifiziert 2026-03-09)_
|
||||
- `spring-cloud-starter-circuitbreaker-reactor-resilience4j` (Reactive/WebFlux-Variante) korrekt eingebunden.
|
||||
- `GatewayConfig.kt`: `circuitBreaker { name = "pingServiceCB"; fallbackUri = "forward:/fallback/ping" }`
|
||||
konfiguriert.
|
||||
- `FallbackController.kt` + `GatewayMetricsConfig.kt` (CB-Counter) vorhanden.
|
||||
- Resilience4j `2.3.0` kompatibel mit Spring Cloud 2025.0.1 / Spring Boot 3.5.x.
|
||||
- [x] **Micrometer 1.16.1:**
|
||||
- Explizites Upgrade in `libs.versions.toml` für Java 25 Kompatibilität.
|
||||
- [ ] **KMP Database Cleanup:**
|
||||
- _(verifiziert 2026-03-09: micrometer = "1.16.1" bereits gesetzt, micrometerTracing = "1.6.1")_
|
||||
- [x] **KMP Database Cleanup:**
|
||||
- Entscheidung: SQLDelight für KMP Client.
|
||||
- Entfernen von Room Dependencies (falls nicht zwingend benötigt).
|
||||
- Exposed Version im Backend prüfen (`0.5x` vs `1.0.0-rc`).
|
||||
- _(verifiziert 2026-03-09: Room entfernt, SQLDelight 2.2.1 aktiv in frontend/core/local-db)_
|
||||
|
||||
### 1.2 Modul-Struktur
|
||||
- [ ] **`core-utils` Refactoring:**
|
||||
- [x] **`core-utils` Refactoring:**
|
||||
- Verschieben von `DatabaseUtils` (JVM-Code) aus `core-utils` nach `:backend:infrastructure:persistence`.
|
||||
- Sicherstellen, dass `core-utils` rein `commonMain` kompatibel ist.
|
||||
- _(verifiziert 2026-03-09: Stub-Datei mit Redirect vorhanden, Code in persistence)_
|
||||
|
||||
---
|
||||
|
||||
## 2. Infrastructure & DevOps (DevOps Engineer)
|
||||
|
||||
### 2.1 Docker Environment
|
||||
- [ ] **Redis -> Valkey Migration:**
|
||||
- [x] **Redis -> Valkey Migration:**
|
||||
- Prüfen, ob wir Redis durch Valkey (Open Source Fork) ersetzen, um Lizenzprobleme zu vermeiden.
|
||||
- Update `docker-compose.yaml`.
|
||||
- [ ] **Keycloak Härtung:**
|
||||
- _(verifiziert 2026-03-09: valkey/valkey:9-alpine läuft produktiv in dc-infra.yaml)_
|
||||
- [x] **Keycloak Härtung:** _(verifiziert 2026-03-09)_
|
||||
- Export der Realm-Config (`meldestelle-realm.json`) und Mounten im Container (statt manueller Config).
|
||||
- Sicherstellen, dass `frontend-client` korrekte Redirect-URIs für Desktop & Web hat.
|
||||
- `frontend-client` (PKCE S256, Custom URI Scheme `meldestelle://callback`) hinzugefügt.
|
||||
- Wildcard `*` aus `webOrigins` aller Clients entfernt (CORS-Härtung).
|
||||
- Password-Policy auf `length(10) + digits + upperCase + specialChars` gestärkt.
|
||||
- `post.logout.redirect.uris` für alle Clients konfiguriert.
|
||||
|
||||
### 2.2 Observability
|
||||
- [ ] **Zipkin Integration:**
|
||||
- Prüfen, ob Traces vom Gateway bis zur DB durchgereicht werden.
|
||||
- Ggf. `micrometer-tracing-bridge-brave` konfigurieren.
|
||||
|
||||
- [x] **Zipkin Integration:** _(verifiziert 2026-03-09)_
|
||||
- `monitoring-client`-Bundle enthält alle nötigen Dependencies (bridge-brave, reporter-brave, sender-okhttp3).
|
||||
- `dc-backend.yaml` setzt `MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://zipkin:9411/api/v2/spans` für alle Services.
|
||||
- Gateway-Fix: `propagation.type` von `w3c` auf `b3` korrigiert; Zipkin-Endpoint explizit ergänzt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend (Frontend Expert)
|
||||
|
||||
### 3.1 Build Fixes
|
||||
- [ ] **Wasm Worker Fix:**
|
||||
- [ ] **Wasm Worker Fix:** _(zurückgestellt — erst nach DevOps-Stabilisierung)_
|
||||
- Behebung der `Unresolved reference: Worker` Fehler im `composeApp:wasmJsBrowserDistribution` Task.
|
||||
- Prüfen der `kotlinx-browser` Version.
|
||||
|
||||
### 3.2 Auth Integration
|
||||
- [ ] **OIDC Client:**
|
||||
- Implementierung des Login-Flows mit `ktor-client-auth` und Keycloak.
|
||||
|
||||
- [x] **OIDC Client:** _(verifiziert 2026-03-09)_
|
||||
- [x] **Ping Service Tracer Bullet:** _(verifiziert 2026-03-09)_
|
||||
- Stack vollständig: Backend (Domain/App/Persistence/Controller/Security/Migrations), Contracts, Gateway-Routing,
|
||||
Frontend (PingScreen, PingViewModel, PingApiKoinClient, Koin DI, Shell-Integration).
|
||||
- Bug behoben: `PingApiKoinClient.syncPings()` `lastSyncTimestamp` → `since` (Delta-Sync Query-Parameter).
|
||||
- PKCE Authorization Code Flow (S256) mit `frontend-client`.
|
||||
- Pure Kotlin SHA-256 + PkceHelper (commonMain, kein expect/actual).
|
||||
- JVM: lokaler Callback-Server (Port 18080) + `Desktop.browse()`.
|
||||
- JS: Seiten-Redirect + URL-Parsing beim App-Start + `replaceState`-Bereinigung.
|
||||
- `LoginViewModel` + `LoginScreen` um OIDC-Button erweitert.
|
||||
|
||||
@@ -13,9 +13,9 @@ owner: DevOps Engineer
|
||||
* ✅ **Gitea Secrets Finalisierung:** Erstellen der Secrets
|
||||
* `REGISTRY_TOKEN` und
|
||||
* `REGISTRY_USER` im Repository „Meldestelle“ (Umgehung der `GITEA_`-Namenssperre).
|
||||
*[ ] **Runner-Stabilisierung:**
|
||||
* Prüfen des Systemd-Status von `act_runner` im Container `infra-runner`.
|
||||
*[ ] Sicherstellen, dass der Status im Gitea-Interface von „Inaktiv“ auf „Aktiv“ springt.
|
||||
*[x] **Runner-Stabilisierung:** _(verifiziert 2026-03-09: Screenshot bestätigt)_
|
||||
* `act_runner` ist `active (running)` seit 06.03.2026, `enabled; preset: enabled`.
|
||||
*[x] Status im Gitea-Interface: Runner ist **Aktiv** (läuft Tasks seit 06.03.2026).
|
||||
*[ ] **Erster Test-Build:**
|
||||
* Erstellen eines Test-Projekts und
|
||||
* Push aus der IDEA auslösen und das Log-Streaming in Gitea Actions verfolgen.
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
# Journal - 2026-03-09
|
||||
|
||||
## 📝 Zusammenfassung
|
||||
|
||||
Keycloak-Härtung der Realm-Konfiguration (`meldestelle-realm.json`). Aufbauend auf dem Infrastruktur-Fix vom
|
||||
2026-03-06 (korrektes `start --optimized`, Healthcheck) wurden nun die Client-Konfigurationen und
|
||||
Sicherheitseinstellungen auf einen produktionsreifen Stand gebracht.
|
||||
|
||||
## 🛠️ Änderungen
|
||||
|
||||
### 1. `config/docker/keycloak/meldestelle-realm.json`
|
||||
|
||||
#### Wildcard aus `webOrigins` entfernt
|
||||
|
||||
- **Vorher:** `api-gateway` und `web-app` hatten `"*"` in `webOrigins` (CORS-Sicherheitslücke).
|
||||
- **Nachher:** Nur explizit erlaubte Origins (`localhost:*`, `app.meldestelle.at`).
|
||||
|
||||
#### Neuer Client: `frontend-client` (KMP Desktop & Mobile)
|
||||
|
||||
- **Public Client** mit **PKCE S256** (kein Client-Secret nötig, sicher für native Apps).
|
||||
- **Redirect URIs:** `meldestelle://callback` (Custom URI Scheme für Desktop), `http://localhost:*` (Dev),
|
||||
`https://app.meldestelle.at/*` (Prod).
|
||||
- **Kein `directAccessGrantsEnabled`** — verhindert Password-Grant-Flow (unsicher für native Apps).
|
||||
|
||||
#### Password-Policy gestärkt
|
||||
|
||||
- **Vorher:** `length(8)` — zu schwach.
|
||||
- **Nachher:** `length(10) and digits(1) and upperCase(1) and specialChars(1) and notUsername(undefined)`.
|
||||
|
||||
#### `post.logout.redirect.uris` konfiguriert
|
||||
|
||||
- Alle relevanten Clients (`api-gateway`, `web-app`, `frontend-client`) haben nun korrekte Logout-Redirect-URIs gemäß
|
||||
OIDC Back-Channel Logout Standard.
|
||||
|
||||
## 📚 Gelerntes / Entscheidungen
|
||||
|
||||
- **`meldestelle://callback`** als Custom URI Scheme: Für KMP Compose Desktop ist ein eigenes URI-Schema der sichere
|
||||
Standard (kein offener HTTP-Port nötig). Muss im OS registriert werden.
|
||||
- **PKCE S256 ist Pflicht für Public Clients:** Verhindert Authorization Code Interception Attacks. Keycloak `26.x`
|
||||
unterstützt dies nativ.
|
||||
- **`frontend-client` vs. `web-app`:** Klare Trennung: `web-app` für Browser-basierte Web-App, `frontend-client` für
|
||||
native KMP Desktop/Mobile-App.
|
||||
|
||||
## 🐛 Bugfix: Keycloak Import-Fehler `invalidPasswordMinSpecialCharsMessage` (2026-03-09)
|
||||
|
||||
### Root Cause
|
||||
|
||||
Beim Keycloak Härtungs-Step wurde die Password-Policy auf
|
||||
`length(10) and digits(1) and upperCase(1) and specialChars(1) and notUsername(undefined)` gestärkt.
|
||||
Der Test-User `admin` in `meldestelle-realm.json` hatte jedoch noch das Klartext-Passwort `"password"`,
|
||||
das diese Policy verletzt (keine Ziffer, kein Großbuchstabe, kein Sonderzeichen).
|
||||
|
||||
Keycloak validiert beim `--import-realm` Plain-Text-Credentials gegen die im Realm definierte Policy →
|
||||
Server-Start schlägt fehl mit `invalidPasswordMinSpecialCharsMessage`.
|
||||
|
||||
### Fix
|
||||
|
||||
- **`config/docker/keycloak/meldestelle-realm.json`**: User `admin` Credential `"password"` → `"Admin1234!"`
|
||||
(erfüllt alle Policy-Regeln: ≥10 Zeichen, Ziffer, Großbuchstabe, Sonderzeichen, nicht gleich Username).
|
||||
|
||||
### Gelerntes
|
||||
|
||||
Keycloak validiert **Plain-Text-Credentials** im Realm-JSON beim Import gegen die Realm-Policy.
|
||||
Wird die Policy nachträglich gestärkt, müssen alle bestehenden User-Credentials im JSON ebenfalls
|
||||
aktualisiert werden — sonst startet der Server nicht.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Micrometer Upgrade (2026-03-09, gleiche Session)
|
||||
|
||||
Verifiziert: `micrometer = "1.16.1"` und `micrometerTracing = "1.6.1"` waren bereits korrekt in
|
||||
`gradle/libs.versions.toml` gesetzt — kein Code-Change erforderlich. Archiv-Roadmaps entsprechend abgehakt.
|
||||
|
||||
## ✅ Zipkin Integration (2026-03-09, gleiche Session)
|
||||
|
||||
Analyse ergab: Infrastruktur war bereits zu ~90% korrekt aufgebaut. Einzige Lücke war eine falsche
|
||||
Propagation-Konfiguration im Gateway.
|
||||
|
||||
### Befund
|
||||
|
||||
- `dc-infra.yaml`: Zipkin-Container (`openzipkin/zipkin:3`, Port 9411) bereits vorhanden.
|
||||
- `monitoring-client`-Bundle: enthält `micrometer-tracing-bridge-brave`, `zipkin-reporter-brave`,
|
||||
`zipkin-sender-okhttp3` — von allen Services eingebunden.
|
||||
- `monitoring-defaults.properties`: setzt `management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans`
|
||||
als Default, überschreibbar via `MANAGEMENT_ZIPKIN_TRACING_ENDPOINT`.
|
||||
- `dc-backend.yaml`: setzt `MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://zipkin:9411/api/v2/spans` für alle
|
||||
Services inkl. Gateway.
|
||||
|
||||
### Fix: Gateway `application.yaml`
|
||||
|
||||
- **`management.tracing.propagation.type`**: `w3c` → `b3`
|
||||
(B3 ist das native Format von Brave/Zipkin; W3C wäre für OpenTelemetry — Mismatch hätte
|
||||
Trace-Korrelation zwischen Gateway und Services gebrochen).
|
||||
- **`management.zipkin.tracing.endpoint`**: Explizit mit lokalem Default + ENV-Override ergänzt.
|
||||
- **`TRACING_SAMPLING_PROBABILITY`**: Konsistent mit Services via ENV-Variable.
|
||||
|
||||
### Fix: Gateway `build.gradle.kts`
|
||||
|
||||
- Redundante direkte `micrometer-tracing-bridge-brave`-Dependency entfernt
|
||||
(bereits transitiv via `monitoring-client` vorhanden).
|
||||
|
||||
## ✅ OIDC Client im Frontend (2026-03-09, gleiche Session)
|
||||
|
||||
Login-Flow mit PKCE Authorization Code Flow (S256) für `frontend-client` implementiert.
|
||||
|
||||
### Neue Dateien
|
||||
|
||||
- **`frontend/core/auth/src/commonMain/.../Sha256.kt`**: Pure Kotlin SHA-256 (FIPS 180-4) + Base64URL-Encoding — kein
|
||||
expect/actual, läuft auf JVM/JS/Wasm.
|
||||
- **`frontend/core/auth/src/commonMain/.../PkceHelper.kt`**: Code Verifier, Code Challenge (S256), State Generator.
|
||||
- **`frontend/core/auth/src/commonMain/.../OidcCallback.kt`**: `OidcCallbackResult` sealed class + expect
|
||||
`launchOidcFlow()`, `consumePendingOidcCallback()`, `getOidcRedirectUri()`.
|
||||
- **`frontend/core/auth/src/jvmMain/.../OidcCallback.jvm.kt`**: Eingebetteter `HttpServer` (Port 18080) +
|
||||
`Desktop.browse()` + `CompletableDeferred` (Timeout 5 min).
|
||||
- **`frontend/core/auth/src/jsMain/.../OidcCallback.js.kt`**: `window.location.href` Redirect + URL-Parameter-Parsing
|
||||
beim App-Start + History-Bereinigung via `replaceState`.
|
||||
|
||||
### Geänderte Dateien
|
||||
|
||||
- **`frontend/core/domain/.../AppConstants.kt`**: `KEYCLOAK_CLIENT_ID` → `frontend-client`, OIDC-Konstanten ergänzt.
|
||||
- **`frontend/core/auth/src/commonMain/.../AuthApiClient.kt`**: `buildAuthorizationUrl()` (PKCE URL-Builder) +
|
||||
`exchangeCodeForToken()` (Code → Token).
|
||||
- **`frontend/core/auth/src/commonMain/.../LoginViewModel.kt`**: `isOidcLoading`-State, `startOidcFlow()`,
|
||||
`handleOidcCallbackResult()`, JS-Init-Callback-Check.
|
||||
- **`frontend/core/auth/src/commonMain/.../LoginScreen.kt`**: Divider + `OutlinedButton` „Mit Keycloak anmelden" mit
|
||||
Spinner bei laufendem Flow.
|
||||
|
||||
### Architektur-Entscheidungen
|
||||
|
||||
- **Kein `ktor-client-auth`**: Der OIDC-Flow wird manuell implementiert — `ktor-client-auth` unterstützt Authorization
|
||||
Code + PKCE nicht nativ für KMP.
|
||||
- **Pure Kotlin SHA-256**: Kein `expect/actual` nötig — `kotlin.math` + reine Bitoperationen reichen aus.
|
||||
- **JVM-Callback-Server** auf `localhost:18080`: Standard-Muster für Desktop-Apps (RFC 8252 „OAuth 2.0 for Native
|
||||
Apps").
|
||||
- **JS-Redirect-Flow**: Kein Popup — volle Seitenweiterleitung. Code Verifier wird in `sessionStorage` gespeichert (nur
|
||||
aktueller Tab).
|
||||
- **State-Validierung**: CSRF-Schutz via State-Parameter-Vergleich im ViewModel.
|
||||
|
||||
## ✅ Gateway CircuitBreaker (2026-03-09, gleiche Session)
|
||||
|
||||
Verifikation ergab: Der ursprüngliche `ClassNotFoundException`-Bug ist durch Spring Cloud `2025.0.1` vollständig
|
||||
behoben.
|
||||
|
||||
### Befund
|
||||
|
||||
- **Dependency:** `spring-cloud-starter-circuitbreaker-reactor-resilience4j` — korrekte Reactive-Variante für
|
||||
WebFlux/Gateway.
|
||||
- **`GatewayConfig.kt`:** CircuitBreaker korrekt als Route-Filter konfiguriert:
|
||||
```kotlin
|
||||
circuitBreaker {
|
||||
it.name = "pingServiceCB"
|
||||
it.fallbackUri = URI.create("forward:/fallback/ping")
|
||||
}
|
||||
```
|
||||
- **`FallbackController.kt`:** Fallback-Endpunkt `/fallback/ping` implementiert.
|
||||
- **`GatewayMetricsConfig.kt`:** CB-Events werden als Prometheus-Counter (`gateway_circuit_breaker_events_total`)
|
||||
erfasst.
|
||||
- **Resilience4j `2.3.0`:** Kompatibel mit Spring Cloud 2025.0.1 / Spring Boot 3.5.x — kein Versionskonflikt.
|
||||
|
||||
### Fazit
|
||||
|
||||
Kein Code-Change erforderlich. Der Fix war implizit durch das Spring Cloud Downgrade auf `2025.0.1` bereits enthalten.
|
||||
|
||||
## ✅ Docker-Stabilität End-to-End (2026-03-09, gleiche Session)
|
||||
|
||||
Vollständige Analyse aller Docker Compose Dateien (`dc-infra`, `dc-backend`, `dc-ops`, `dc-gui`) sowie `.env` und
|
||||
gemounteter Konfigs.
|
||||
|
||||
### Befund & Fixes
|
||||
|
||||
#### Fix 1: `.env` — `KC_COMMAND` Regression 🔴
|
||||
|
||||
- **Problem:** `.env` hatte `KC_COMMAND=start-dev --import-realm` — exakt der Bug vom 2026-03-06 Log. Das pre-built
|
||||
Keycloak-Image startet im falschen Dev-Modus, OIDC-Flow schlägt fehl.
|
||||
- **Fix:** `KC_COMMAND=start --optimized --import-realm`
|
||||
|
||||
#### Fix 2: `base-application.yaml` — Valkey Env-Var-Namen 🟠
|
||||
|
||||
- **Problem:** `base-application.yaml` (gemountet in allen Backend-Services) nutzte `${SPRING_DATA_REDIS_HOST}` /
|
||||
`${SPRING_DATA_REDIS_PORT}` / `${SPRING_DATA_REDIS_PASSWORD}` als Env-Var-Namen. `dc-backend.yaml` setzt aber nur
|
||||
`SPRING_DATA_VALKEY_*` → Redis-Host fiel auf `localhost`-Default zurück statt auf den `valkey`-Container.
|
||||
- **Fix:** Env-Var-Namen auf `${SPRING_DATA_VALKEY_HOST}`, `${SPRING_DATA_VALKEY_PORT}`,
|
||||
`${SPRING_DATA_VALKEY_PASSWORD}` umgestellt. Spring Boot Property-Pfad `spring.data.redis.*` bleibt korrekt.
|
||||
|
||||
#### Fix 3: `gateway/application.yaml` — Ungültiger Property-Namespace 🟠
|
||||
|
||||
- **Problem:** Gateway nutzte `spring.data.valkey.*` — kein valider Spring Boot Auto-Konfigurations-Namespace. Spring
|
||||
Boot kennt nur `spring.data.redis.*` für die Redis/Lettuce-Autoconfiguration.
|
||||
- **Fix:** `spring.data.valkey` → `spring.data.redis` (Env-Vars `SPRING_DATA_VALKEY_*` bleiben).
|
||||
|
||||
### Verifiziert: Korrekte Konfigurationen
|
||||
|
||||
- **Startup-Reihenfolge:** Postgres → Keycloak → Consul → Valkey → Gateway/Ping (via `depends_on` + Healthchecks) ✅
|
||||
- **Netzwerk:** Alle Services im `meldestelle-network` ✅
|
||||
- **Zipkin:** `service_started` (stateless, kein Healthcheck nötig) ✅
|
||||
- **Consul Healthcheck:** curl auf `/v1/status/leader` ✅
|
||||
- **Keycloak Healthcheck:** curl auf `localhost:9000/health/ready` ✅
|
||||
|
||||
## 🐛 Bugfix: Keycloak Healthcheck schlägt fehl obwohl Keycloak läuft (2026-03-09)
|
||||
|
||||
### Symptom
|
||||
|
||||
`docker compose --profile infra up -d` — Keycloak startet korrekt (Port 8080 + 9000 gebunden,
|
||||
Realm importiert), wird von Docker aber nie als `healthy` markiert. Dieses Problem existierte
|
||||
bereits früher und führte damals zur Entfernung des Healthchecks.
|
||||
|
||||
### Root Cause 1: `/health/ready` vs. `/health/live`
|
||||
|
||||
Keycloak 26.x führt beim Start den JGroups-Cluster-Join-Prozess aus. Bei Single-Node-Betrieb
|
||||
(kein anderer Knoten im Netz) versucht Keycloak 10× einem Ghost-Node beizutreten — jeder Versuch
|
||||
dauert 2 Sekunden = **20 Sekunden Verzögerung** nach dem eigentlichen Startup.
|
||||
|
||||
- **`/health/ready`** wartet auf vollständige Cluster-Formation → gibt während der JOIN-Phase `503`
|
||||
zurück.
|
||||
- **`/health/live`** prüft nur ob der Prozess läuft — unabhängig vom Cluster-Status.
|
||||
|
||||
Mit `start_period: 60s` und `interval: 15s` feuerte der erste Check in genau diesem 20s-Fenster
|
||||
→ false-negative → nach 10 Retries → `unhealthy`.
|
||||
|
||||
### Root Cause 2: `KC_HOSTNAME_STRICT_HTTPS` — deprecated v1-Option
|
||||
|
||||
`WARNING: Hostname v1 options [hostname-strict-https] are still in use` erscheint in jedem
|
||||
Startup-Log. In Keycloak 26.x wurde das Hostname-System auf v2 umgestellt. `KC_HOSTNAME_STRICT_HTTPS`
|
||||
ist ein v1-Parameter der ignoriert wird — HTTP-Zugriff wird ausschließlich über `KC_HTTP_ENABLED`
|
||||
gesteuert.
|
||||
|
||||
### Fixes in `dc-infra.yaml`
|
||||
|
||||
| Parameter | Vorher | Nachher | Begründung |
|
||||
|----------------------------|------------------|-----------------|------------------------------------------------------|
|
||||
| `test` | `…/health/ready` | `…/health/live` | Kein false-negative während JGroups-JOIN |
|
||||
| `start_period` | `"60s"` | `"120s"` | Mehr Puffer für langsamen First-Start (Realm-Import) |
|
||||
| `interval` | `"15s"` | `"20s"` | Weniger Stress während Startup-Phase |
|
||||
| `timeout` | `"5s"` | `"10s"` | Mehr Zeit für Endpoint-Antwort |
|
||||
| `retries` | `"10"` (String) | `10` (Integer) | Korrekter YAML-Typ |
|
||||
| `KC_HOSTNAME_STRICT_HTTPS` | gesetzt | **entfernt** | Deprecated v1-Option, erzeugt Warning |
|
||||
|
||||
### Gelerntes
|
||||
|
||||
- **`/health/live` ist für Single-Node-Keycloak die richtige Wahl** — `ready` ist für Cluster-Szenarien
|
||||
konzipiert wo mehrere Nodes koordiniert hochfahren müssen.
|
||||
- `retries` in Docker Compose Healthcheck sollte als Integer, nicht als String angegeben werden.
|
||||
|
||||
## ✅ Ping Service — Tracer Bullet Analyse & Fix (2026-03-09, gleiche Session)
|
||||
|
||||
Vollständige Analyse des Ping Service Stacks (Backend → Gateway → Frontend). Ergebnis: Die gesamte
|
||||
fachliche Implementierung war bereits vorhanden. Ein einziger kritischer Bug gefunden und behoben.
|
||||
|
||||
### Befund: Stack vollständig implementiert
|
||||
|
||||
- **Backend** (`backend/services/ping/ping-service`): Domain, Application, Persistence, Controller,
|
||||
Security (`at.mocode.infrastructure.security` via `@ComponentScan`), DB-Migrations V1+V2 — vollständig.
|
||||
- **Contracts** (`contracts/ping-api`): `PingResponse`, `EnhancedPingResponse`, `HealthResponse`,
|
||||
`PingEvent`, `PingApi` — vollständig.
|
||||
- **Gateway** (`backend/infrastructure/gateway`): Routing `/api/ping/**` → `stripPrefix(1)` →
|
||||
Ping Service mit CircuitBreaker Fallback — korrekt.
|
||||
- **Frontend** (`frontend/features/ping-feature`): `PingApiKoinClient`, `PingViewModel`, `PingScreen`,
|
||||
`PingSyncService`, Koin DI — vollständig.
|
||||
- **Shell-Integration**: `MainApp.kt` — `AppScreen.Ping` eingebunden, „Ping-Service"-Button auf Home-Screen,
|
||||
Navigation korrekt.
|
||||
|
||||
### Fix: `PingApiKoinClient.kt` — Query-Parameter-Mismatch 🔴
|
||||
|
||||
- **Problem:** `syncPings()` sendete `?lastSyncTimestamp=...` als Query-Parameter.
|
||||
Backend `@RequestParam` erwartet `?since=...` — Delta-Sync lieferte immer alle Daten.
|
||||
- **Fix:** `url.parameters.append("lastSyncTimestamp", ...)` → `url.parameters.append("since", ...)`
|
||||
|
||||
## ✅ Gitea-Pipeline & Runner Analyse (2026-03-09, gleiche Session)
|
||||
|
||||
### Befund: Aktiver CI/CD-Kern
|
||||
|
||||
- `.gitea/workflows/docker-publish.yaml` ist der einzige aktiv ausgeführte Workflow — grün seit 06.03.2026.
|
||||
- Matrix: 4 Services (`keycloak`, `api-gateway`, `ping-service`, `web-app`), `max-parallel: 1` (RAM-Schutz).
|
||||
- Native `linux/arm64`-Builds auf VM 102 (`10.0.0.23`), Push in interne Registry `10.0.0.22:3000`.
|
||||
|
||||
### Verifiziert (Screenshot 2026-03-09_12-47)
|
||||
|
||||
- `insecure-registries: ["10.0.0.22:3000"]` permanent in `/etc/docker/daemon.json` gesetzt ✅
|
||||
- `act_runner`: `active (running)` seit 06.03.2026, `enabled; preset: enabled` ✅
|
||||
- `systemctl is-enabled act_runner` → `enabled` ✅
|
||||
|
||||
### Bereinigung `.github/`-Ordner
|
||||
|
||||
- **Problem:** 6 GitHub Actions Workflows in `.github/workflows/` — auf Gitea nie ausgeführt (toter Code).
|
||||
- **Zusätzlich:** `ci-main.yml`, `deploy-proxmox.yml`, `ssot-guard.yml` referenzierten falschen Pfad
|
||||
`docker/docker-compose.yaml`.
|
||||
- **Fix:** `pr-guard.yml` (prüft hartcodierte Versionen in `build.gradle.kts`) nach `.gitea/workflows/pr-guard.yaml`
|
||||
migriert.
|
||||
- **Fix:** Gesamten `.github/`-Ordner gelöscht.
|
||||
- `youtrack-sync.yml` war nicht aktiv und wurde mitgelöscht — kein Handlungsbedarf.
|
||||
|
||||
### Aktive Workflows nach Bereinigung
|
||||
|
||||
| Datei | Trigger | Zweck |
|
||||
|----------------------------------------|--------------|------------------------------------------------|
|
||||
| `.gitea/workflows/docker-publish.yaml` | Push `main` | Build & Push aller Docker-Images |
|
||||
| `.gitea/workflows/pr-guard.yaml` | Pull Request | Prüft hartcodierte Versionen in Gradle-Dateien |
|
||||
|
||||
## Bugfix: Keycloak Healthcheck — Finale Korrektur (Port 8080 → 9000)
|
||||
|
||||
**Problem:** Keycloak 26.5.5 startet sauber (~15s), wird aber dauerhaft nicht `healthy`.
|
||||
|
||||
**Root Cause (final):** Der vorherige Fix hatte Port 9000 → 8080 geändert — das war **falsch**.
|
||||
|
||||
| Port | Zweck | Health-Endpoint |
|
||||
|----------|-----------------------------------------|-------------------------------------|
|
||||
| **8080** | Haupt-HTTP (Login, Admin-Console, APIs) | ❌ kennt `/health/*` **nicht** → 404 |
|
||||
| **9000** | Management (Actuator, Health, Metrics) | ✅ `/health/live`, `/health/ready` |
|
||||
|
||||
`curl http://localhost:8080/health/live` → **404** → `curl -f` schlägt fehl → `unhealthy`.
|
||||
|
||||
**Fix `dc-infra.yaml`:**
|
||||
|
||||
```yaml
|
||||
test: ["CMD-SHELL", "curl -sf --max-time 5 http://localhost:9000/health/live || exit 1"]
|
||||
interval: "15s"
|
||||
timeout: "10s"
|
||||
retries: 5
|
||||
start_period: "90s"
|
||||
```
|
||||
|
||||
**Warum `/health/live` statt `/health/ready`?**
|
||||
`/health/ready` wartet auf JGroups-Cluster-Formation (Single-Node: 10×2s JOIN-Timeout = 20s Verzögerung). `/health/live`
|
||||
prüft nur Prozess-Liveness — sofort `UP` sobald Quarkus läuft.
|
||||
|
||||
**`start_period: 90s`** — Puffer für First-Run mit DB-Schema-Init (Liquibase-Migrations ~15–20s).
|
||||
|
||||
## 🔜 Nächste Schritte
|
||||
|
||||
- **TLS/HTTPS** — Langfristig: `KC_HOSTNAME_STRICT_HTTPS=true` setzen, sobald TLS eingerichtet ist.
|
||||
- **Entries Service** — Beginn der Implementierung des ersten echten Fach-Services ("Nennungen").
|
||||
|
||||
---
|
||||
|
||||
## Bugfix: Keycloak Healthcheck — Echter Root Cause (curl nicht vorhanden)
|
||||
|
||||
**Zeitpunkt:** 2026-03-09 ~15:15
|
||||
|
||||
### Root Cause
|
||||
|
||||
Das Keycloak-Image `quay.io/keycloak/keycloak:26.5.5` basiert auf **`ubi9-micro`** — einem minimalen Red Hat Image *
|
||||
*ohne `curl` / `wget` / Package Manager**.
|
||||
Alle bisherigen Port-Fixes (8080 → 9000) waren korrekt, aber der `curl`-Befehl scheiterte im Container mit
|
||||
`command not found`.
|
||||
Keycloak selbst lief die ganze Zeit einwandfrei — nur der Healthcheck-Befehl fehlte das nötige Tool.
|
||||
|
||||
### Fix
|
||||
|
||||
| Datei | Änderung |
|
||||
|-----------------|---------------------------------------------|
|
||||
| `dc-infra.yaml` | Healthcheck: `curl` → Bash `/dev/tcp`-Trick |
|
||||
|
||||
**Neuer Healthcheck (kein externes Tool nötig):**
|
||||
|
||||
```yaml
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && printf 'GET /health/live HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3 && cat <&3 | grep -q '"status":"UP"'"]
|
||||
```
|
||||
|
||||
### Warum `/dev/tcp` funktioniert
|
||||
|
||||
- Bash hat `/dev/tcp` eingebaut — verfügbar ohne Installation
|
||||
- `exec 3<>/dev/tcp/localhost/9000` öffnet TCP-Verbindung auf Port 9000
|
||||
- `printf ... >&3` sendet einen minimalen HTTP/1.0 GET-Request
|
||||
- `cat <&3 | grep -q '"status":"UP"'` liest Antwort und prüft Health-Status
|
||||
|
||||
### Gelernt
|
||||
|
||||
> Vor jedem `curl`-basierten Healthcheck prüfen: Ist `curl` im Container-Image vorhanden?
|
||||
> Minimale Images (`ubi9-micro`, `distroless`, `alpine` ohne curl-Paket) haben es **nicht**.
|
||||
> Sicherer Standard: `/dev/tcp` (bash built-in) statt `curl` — funktioniert immer wenn bash vorhanden ist.
|
||||
|
||||
---
|
||||
|
||||
## Bugfix: Keycloak Healthcheck — grep-Pattern Leerzeichen
|
||||
|
||||
**Zeitpunkt:** 2026-03-09 ~15:27
|
||||
|
||||
### Root Cause
|
||||
|
||||
Nach dem `/dev/tcp`-Fix war der Healthcheck immer noch `unhealthy` (ExitCode=1).
|
||||
Der grep-Pattern lautete `'"status":"UP"'` (kein Leerzeichen nach `:`), die tatsächliche
|
||||
JSON-Antwort von Keycloak enthält jedoch `"status": "UP"` (mit Leerzeichen nach dem Doppelpunkt).
|
||||
|
||||
**Beweis via `docker exec`:**
|
||||
|
||||
```
|
||||
HTTP/1.0 200 OK
|
||||
{
|
||||
"status": "UP",
|
||||
"checks": []
|
||||
}
|
||||
```
|
||||
|
||||
grep nach `"status":"UP"` → kein Match → ExitCode 1 → unhealthy.
|
||||
|
||||
### Fix
|
||||
|
||||
| Datei | Änderung |
|
||||
|-----------------|--------------------------------------------------|
|
||||
| `dc-infra.yaml` | grep-Pattern: `"status":"UP"` → `"status": "UP"` |
|
||||
|
||||
**Vorher:**
|
||||
|
||||
```yaml
|
||||
test: ["CMD-SHELL", "... | grep -q '\"status\":\"UP\"'"]
|
||||
```
|
||||
|
||||
**Nachher:**
|
||||
|
||||
```yaml
|
||||
test: ["CMD-SHELL", "... | grep -q '\"status\": \"UP\"'"]
|
||||
```
|
||||
|
||||
### Ergebnis
|
||||
|
||||
Nach `docker compose up -d keycloak`: Status **healthy**, 2x ExitCode=0 bestätigt.
|
||||
|
||||
### Gelernt
|
||||
|
||||
> Den Healthcheck-Befehl immer manuell via `docker exec` testen und die **exakte** API-Antwort
|
||||
> prüfen — JSON-Formatierung (Leerzeichen, Einrückung) kann je nach Keycloak-Version variieren.
|
||||
> Sicherer wäre `grep -q '"UP"'` ohne den Key, um Formatierungsunterschiede zu vermeiden.
|
||||
|
||||
---
|
||||
|
||||
## Optimierung: Healthcheck grep-Pattern vereinfacht
|
||||
|
||||
**Zeitpunkt:** 2026-03-09 ~15:30
|
||||
|
||||
### Maßnahme
|
||||
|
||||
Das grep-Pattern wurde weiter vereinfacht auf `grep -q '"UP"'`, um robust gegen
|
||||
JSON-Formatierungsänderungen (Leerzeichen, Einrückung) in zukünftigen Keycloak-Versionen zu sein.
|
||||
|
||||
| Datei | Vorher | Nachher |
|
||||
|-----------------|----------------------------|------------------|
|
||||
| `dc-infra.yaml` | `grep -q '"status": "UP"'` | `grep -q '"UP"'` |
|
||||
|
||||
### Ergebnis
|
||||
|
||||
Status **healthy**, ExitCode=0 bestätigt. Pattern ist nun versionsunabhängig.
|
||||
|
||||
---
|
||||
|
||||
## Bugfix: Login fehlgeschlagen — `directAccessGrantsEnabled` für `frontend-client`
|
||||
|
||||
**Zeitpunkt:** 2026-03-09 ~15:41
|
||||
|
||||
### Symptom
|
||||
|
||||
Web-App (`localhost:4000`) zeigte beim direkten Username/Passwort-Login:
|
||||
|
||||
```
|
||||
Login fehlgeschlagen: HTTP 400 - {"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
|
||||
`AuthApiClient.kt` verwendet `AppConstants.KEYCLOAK_CLIENT_ID = "frontend-client"` mit
|
||||
`grant_type=password` (Resource Owner Password Credentials / ROPC-Flow).
|
||||
Der `frontend-client` hatte in `meldestelle-realm.json` jedoch `directAccessGrantsEnabled: false` —
|
||||
bewusst deaktiviert als reiner PKCE-Client, dabei aber vergessen, dass die Web-App ROPC nutzt.
|
||||
|
||||
### Fix
|
||||
|
||||
| Datei | Änderung |
|
||||
|--------------------------|----------------------------------------------------------------|
|
||||
| `meldestelle-realm.json` | `frontend-client`: `directAccessGrantsEnabled: false` → `true` |
|
||||
|
||||
Live-Update via Admin REST API (kein Neustart nötig):
|
||||
|
||||
```bash
|
||||
# Token holen
|
||||
TOKEN=$(curl -s -X POST "http://localhost:8180/realms/master/protocol/openid-connect/token" \
|
||||
-d "username=kc-admin&password=kc-password&grant_type=password&client_id=admin-cli" ...)
|
||||
|
||||
# Client-UUID ermitteln & PUT mit directAccessGrantsEnabled:true
|
||||
curl -X PUT "http://localhost:8180/admin/realms/meldestelle/clients/$UUID" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$UPDATED_CLIENT_JSON"
|
||||
# → HTTP 204
|
||||
```
|
||||
|
||||
### Verifikation
|
||||
|
||||
```bash
|
||||
curl -X POST ".../realms/meldestelle/protocol/openid-connect/token" \
|
||||
-d "username=admin&password=Admin%231234&grant_type=password&client_id=frontend-client"
|
||||
# → "token_type":"Bearer" ✅
|
||||
```
|
||||
|
||||
### Gelernt
|
||||
|
||||
> `--import-realm` in Keycloak importiert Realms **nur beim ersten Start** (wenn Realm noch nicht existiert).
|
||||
> Änderungen an der realm.json nach dem ersten Import müssen entweder via Admin REST API oder
|
||||
> durch Löschen des Volumes (Datenverlust!) neu eingespielt werden.
|
||||
> Für Dev-Umgebungen: `KC_COMMAND=start --optimized --import-realm` mit separatem Reset-Script
|
||||
> oder Admin-API-Skript für Konfigurationsänderungen vorbereiten.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
+86
@@ -132,6 +132,92 @@ class AuthApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Authorization URL für den PKCE Authorization Code Flow.
|
||||
* Redirect URI muss in Keycloak als erlaubt konfiguriert sein.
|
||||
*/
|
||||
fun buildAuthorizationUrl(
|
||||
redirectUri: String,
|
||||
codeVerifier: String,
|
||||
state: String,
|
||||
scopes: String = "openid profile email"
|
||||
): String {
|
||||
val challenge = PkceHelper.computeCodeChallengeS256(codeVerifier)
|
||||
val base = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/auth"
|
||||
return buildString {
|
||||
append(base)
|
||||
append("?response_type=code")
|
||||
append("&client_id=").append(clientId)
|
||||
append("&redirect_uri=").append(redirectUri.encodeUrlParam())
|
||||
append("&scope=").append(scopes.encodeUrlParam())
|
||||
append("&state=").append(state)
|
||||
append("&code_challenge=").append(challenge)
|
||||
append("&code_challenge_method=S256")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauscht den Authorization Code gegen ein Access Token aus (PKCE).
|
||||
*/
|
||||
suspend fun exchangeCodeForToken(
|
||||
code: String,
|
||||
codeVerifier: String,
|
||||
redirectUri: String
|
||||
): LoginResponse {
|
||||
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
||||
return try {
|
||||
val response = httpClient.submitForm(
|
||||
url = tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "authorization_code")
|
||||
append("client_id", clientId)
|
||||
append("code", code)
|
||||
append("redirect_uri", redirectUri)
|
||||
append("code_verifier", codeVerifier)
|
||||
}
|
||||
) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val kc = response.body<KeycloakTokenResponse>()
|
||||
LoginResponse(
|
||||
success = true,
|
||||
token = kc.accessToken,
|
||||
message = null
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.bodyAsText()
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Token-Austausch fehlgeschlagen: HTTP ${response.status.value} - $errorBody"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Token-Austausch Fehler: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Einfaches URL-Encoding für Query-Parameter-Werte.
|
||||
* Ersetzt die Zeichen, die in URL-Parametern kodiert werden müssen.
|
||||
*/
|
||||
internal fun String.encodeUrlParam(): String = this
|
||||
.replace("%", "%25")
|
||||
.replace(" ", "%20")
|
||||
.replace("+", "%2B")
|
||||
.replace("&", "%26")
|
||||
.replace("=", "%3D")
|
||||
.replace("?", "%3F")
|
||||
.replace("#", "%23")
|
||||
.replace(":", "%3A")
|
||||
.replace("/", "%2F")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class KeycloakTokenResponse(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package at.mocode.frontend.core.auth.data
|
||||
|
||||
/**
|
||||
* Ergebnis eines OIDC-Callback-Empfangs (Authorization Code + State).
|
||||
*/
|
||||
sealed class OidcCallbackResult {
|
||||
/** Authorization Code wurde erfolgreich empfangen. */
|
||||
data class Success(val code: String, val state: String) : OidcCallbackResult()
|
||||
|
||||
/** Fehler vom Authorization Server (z. B. access_denied). */
|
||||
data class Error(val error: String, val description: String? = null) : OidcCallbackResult()
|
||||
|
||||
/** Timeout — Benutzer hat den Login-Vorgang nicht abgeschlossen. */
|
||||
object Timeout : OidcCallbackResult()
|
||||
|
||||
/** JS-spezifisch: Seite wurde weitergeleitet, Ergebnis kommt beim nächsten Start. */
|
||||
object Redirecting : OidcCallbackResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den plattformspezifischen OIDC-Flow:
|
||||
* - JVM: öffnet Browser + wartet auf Callback über lokalen HTTP-Server
|
||||
* - JS: leitet die Seite zu Keycloak weiter (keine Rückkehr bis Neustart)
|
||||
*/
|
||||
expect suspend fun launchOidcFlow(
|
||||
authUrl: String,
|
||||
callbackPort: Int
|
||||
): OidcCallbackResult
|
||||
|
||||
/**
|
||||
* Prüft beim App-Start, ob in der aktuellen URL ein OIDC-Callback steckt.
|
||||
* Relevant für JS/Browser: nach Keycloak-Redirect enthält die URL code= und state=.
|
||||
* Gibt null zurück, wenn kein Callback vorhanden.
|
||||
*/
|
||||
expect fun consumePendingOidcCallback(): OidcCallbackResult?
|
||||
|
||||
/**
|
||||
* Gibt die plattformspezifische Redirect URI zurück:
|
||||
* - JVM: http://localhost:18080/callback
|
||||
* - JS: window.location.origin + /auth/callback
|
||||
*/
|
||||
expect fun getOidcRedirectUri(): String
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package at.mocode.frontend.core.auth.data
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* PKCE-Hilfsfunktionen für den OIDC Authorization Code Flow mit S256.
|
||||
* RFC 7636: Proof Key for Code Exchange
|
||||
*/
|
||||
internal object PkceHelper {
|
||||
|
||||
/**
|
||||
* Erzeugt einen zufälligen Code Verifier (43–128 Zeichen, URL-safe Base64).
|
||||
* RFC 7636 §4.1: unreserved characters [A-Z / a-z / 0-9 / "-" / "." / "_" / "~"]
|
||||
*/
|
||||
fun generateCodeVerifier(): String {
|
||||
val bytes = ByteArray(32)
|
||||
Random.nextBytes(bytes)
|
||||
return bytes.encodeBase64Url()
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den Code Challenge aus dem Verifier via SHA-256 + Base64URL.
|
||||
* RFC 7636 §4.2: code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
|
||||
*/
|
||||
fun computeCodeChallengeS256(codeVerifier: String): String {
|
||||
val hash = Sha256.hash(codeVerifier.encodeToByteArray())
|
||||
return hash.encodeBase64Url()
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen zufälligen State-Parameter für CSRF-Schutz.
|
||||
*/
|
||||
fun generateState(): String {
|
||||
val bytes = ByteArray(16)
|
||||
Random.nextBytes(bytes)
|
||||
return bytes.encodeBase64Url()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package at.mocode.frontend.core.auth.data
|
||||
|
||||
/**
|
||||
* Reines Kotlin SHA-256 nach FIPS 180-4.
|
||||
* Läuft auf allen KMP-Plattformen ohne expect/actual (JVM, JS, Wasm).
|
||||
*/
|
||||
internal object Sha256 {
|
||||
|
||||
private val K = intArrayOf(
|
||||
0x428a2f98.toInt(), 0x71374491.toInt(), 0xb5c0fbcf.toInt(), 0xe9b5dba5.toInt(),
|
||||
0x3956c25b.toInt(), 0x59f111f1.toInt(), 0x923f82a4.toInt(), 0xab1c5ed5.toInt(),
|
||||
0xd807aa98.toInt(), 0x12835b01.toInt(), 0x243185be.toInt(), 0x550c7dc3.toInt(),
|
||||
0x72be5d74.toInt(), 0x80deb1fe.toInt(), 0x9bdc06a7.toInt(), 0xc19bf174.toInt(),
|
||||
0xe49b69c1.toInt(), 0xefbe4786.toInt(), 0x0fc19dc6.toInt(), 0x240ca1cc.toInt(),
|
||||
0x2de92c6f.toInt(), 0x4a7484aa.toInt(), 0x5cb0a9dc.toInt(), 0x76f988da.toInt(),
|
||||
0x983e5152.toInt(), 0xa831c66d.toInt(), 0xb00327c8.toInt(), 0xbf597fc7.toInt(),
|
||||
0xc6e00bf3.toInt(), 0xd5a79147.toInt(), 0x06ca6351.toInt(), 0x14292967.toInt(),
|
||||
0x27b70a85.toInt(), 0x2e1b2138.toInt(), 0x4d2c6dfc.toInt(), 0x53380d13.toInt(),
|
||||
0x650a7354.toInt(), 0x766a0abb.toInt(), 0x81c2c92e.toInt(), 0x92722c85.toInt(),
|
||||
0xa2bfe8a1.toInt(), 0xa81a664b.toInt(), 0xc24b8b70.toInt(), 0xc76c51a3.toInt(),
|
||||
0xd192e819.toInt(), 0xd6990624.toInt(), 0xf40e3585.toInt(), 0x106aa070.toInt(),
|
||||
0x19a4c116.toInt(), 0x1e376c08.toInt(), 0x2748774c.toInt(), 0x34b0bcb5.toInt(),
|
||||
0x391c0cb3.toInt(), 0x4ed8aa4a.toInt(), 0x5b9cca4f.toInt(), 0x682e6ff3.toInt(),
|
||||
0x748f82ee.toInt(), 0x78a5636f.toInt(), 0x84c87814.toInt(), 0x8cc70208.toInt(),
|
||||
0x90befffa.toInt(), 0xa4506ceb.toInt(), 0xbef9a3f7.toInt(), 0xc67178f2.toInt()
|
||||
)
|
||||
|
||||
fun hash(input: ByteArray): ByteArray {
|
||||
var h0 = 0x6a09e667.toInt()
|
||||
var h1 = 0xbb67ae85.toInt()
|
||||
var h2 = 0x3c6ef372.toInt()
|
||||
var h3 = 0xa54ff53a.toInt()
|
||||
var h4 = 0x510e527f.toInt()
|
||||
var h5 = 0x9b05688c.toInt()
|
||||
var h6 = 0x1f83d9ab.toInt()
|
||||
var h7 = 0x5be0cd19.toInt()
|
||||
|
||||
val msgLen = input.size
|
||||
val bitLen = msgLen.toLong() * 8
|
||||
val paddedLen = ((msgLen + 9 + 63) / 64) * 64
|
||||
val msg = ByteArray(paddedLen)
|
||||
input.copyInto(msg)
|
||||
msg[msgLen] = 0x80.toByte()
|
||||
for (i in 0..7) {
|
||||
msg[paddedLen - 8 + i] = ((bitLen ushr (56 - i * 8)) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
val w = IntArray(64)
|
||||
var offset = 0
|
||||
while (offset < paddedLen) {
|
||||
for (i in 0..15) {
|
||||
w[i] = ((msg[offset + i * 4].toInt() and 0xFF) shl 24) or
|
||||
((msg[offset + i * 4 + 1].toInt() and 0xFF) shl 16) or
|
||||
((msg[offset + i * 4 + 2].toInt() and 0xFF) shl 8) or
|
||||
(msg[offset + i * 4 + 3].toInt() and 0xFF)
|
||||
}
|
||||
for (i in 16..63) {
|
||||
val s0 = w[i - 15].rotateRight(7) xor w[i - 15].rotateRight(18) xor (w[i - 15] ushr 3)
|
||||
val s1 = w[i - 2].rotateRight(17) xor w[i - 2].rotateRight(19) xor (w[i - 2] ushr 10)
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1
|
||||
}
|
||||
|
||||
var a = h0;
|
||||
var b = h1;
|
||||
var c = h2;
|
||||
var d = h3
|
||||
var e = h4;
|
||||
var f = h5;
|
||||
var g = h6;
|
||||
var h = h7
|
||||
|
||||
for (i in 0..63) {
|
||||
val s1 = e.rotateRight(6) xor e.rotateRight(11) xor e.rotateRight(25)
|
||||
val ch = (e and f) xor (e.inv() and g)
|
||||
val temp1 = h + s1 + ch + K[i] + w[i]
|
||||
val s0 = a.rotateRight(2) xor a.rotateRight(13) xor a.rotateRight(22)
|
||||
val maj = (a and b) xor (a and c) xor (b and c)
|
||||
val temp2 = s0 + maj
|
||||
h = g; g = f; f = e; e = d + temp1
|
||||
d = c; c = b; b = a; a = temp1 + temp2
|
||||
}
|
||||
|
||||
h0 += a; h1 += b; h2 += c; h3 += d
|
||||
h4 += e; h5 += f; h6 += g; h7 += h
|
||||
offset += 64
|
||||
}
|
||||
|
||||
val result = ByteArray(32)
|
||||
val vals = intArrayOf(h0, h1, h2, h3, h4, h5, h6, h7)
|
||||
for (i in vals.indices) {
|
||||
result[i * 4] = (vals[i] ushr 24).toByte()
|
||||
result[i * 4 + 1] = (vals[i] ushr 16).toByte()
|
||||
result[i * 4 + 2] = (vals[i] ushr 8).toByte()
|
||||
result[i * 4 + 3] = vals[i].toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Base64URL-Kodierung ohne Padding (RFC 4648 §5). */
|
||||
internal fun ByteArray.encodeBase64Url(): String {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
while (i < size) {
|
||||
val b0 = get(i).toInt() and 0xFF
|
||||
val b1 = if (i + 1 < size) get(i + 1).toInt() and 0xFF else 0
|
||||
val b2 = if (i + 2 < size) get(i + 2).toInt() and 0xFF else 0
|
||||
sb.append(chars[b0 shr 2])
|
||||
sb.append(chars[((b0 and 3) shl 4) or (b1 shr 4)])
|
||||
if (i + 1 < size) sb.append(chars[((b1 and 15) shl 2) or (b2 shr 6)])
|
||||
if (i + 2 < size) sb.append(chars[b2 and 63])
|
||||
i += 3
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
+38
-2
@@ -56,7 +56,7 @@ fun LoginScreen(
|
||||
value = uiState.username,
|
||||
onValueChange = viewModel::updateUsername,
|
||||
label = { Text("Benutzername") },
|
||||
enabled = !uiState.isLoading,
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
isError = uiState.usernameError != null,
|
||||
supportingText = uiState.usernameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
@@ -76,7 +76,7 @@ fun LoginScreen(
|
||||
value = uiState.password,
|
||||
onValueChange = viewModel::updatePassword,
|
||||
label = { Text("Passwort") },
|
||||
enabled = !uiState.isLoading,
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
isError = uiState.passwordError != null,
|
||||
supportingText = uiState.passwordError?.let { { Text(it) } },
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
@@ -147,6 +147,42 @@ fun LoginScreen(
|
||||
Text("Anmelden")
|
||||
}
|
||||
}
|
||||
|
||||
// Divider
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = " oder ",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// OIDC / Keycloak button
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.startOidcFlow() },
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
if (uiState.isOidcLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Weiterleitung ...")
|
||||
} else {
|
||||
Text("Mit Keycloak anmelden")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+123
-35
@@ -2,8 +2,8 @@ package at.mocode.frontend.core.auth.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.frontend.core.auth.data.AuthApiClient
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.auth.data.*
|
||||
import at.mocode.frontend.core.domain.AppConstants
|
||||
import io.ktor.client.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -17,17 +17,20 @@ data class LoginUiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isOidcLoading: Boolean = false,
|
||||
val isAuthenticated: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val usernameError: String? = null,
|
||||
val passwordError: String? = null
|
||||
) {
|
||||
val canLogin: Boolean
|
||||
get() = username.isNotBlank() && password.isNotBlank() && !isLoading
|
||||
get() = username.isNotBlank() && password.isNotBlank() && !isLoading && !isOidcLoading
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for handling login authentication logic
|
||||
* ViewModel for handling login authentication logic.
|
||||
* Unterstützt sowohl Password Grant (web-app) als auch
|
||||
* PKCE Authorization Code Flow (frontend-client).
|
||||
*/
|
||||
class LoginViewModel(
|
||||
private val authTokenManager: AuthTokenManager,
|
||||
@@ -38,19 +41,28 @@ class LoginViewModel(
|
||||
private val _uiState = MutableStateFlow(LoginUiState())
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
// PKCE-State für den laufenden OIDC-Flow (in-memory)
|
||||
private var pendingCodeVerifier: String? = null
|
||||
private var pendingState: String? = null
|
||||
|
||||
init {
|
||||
// Observe AuthTokenManager state to keep UI in sync
|
||||
// AuthTokenManager-State beobachten → UI synchron halten
|
||||
viewModelScope.launch {
|
||||
authTokenManager.authState.collect { authState ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isAuthenticated = authState.isAuthenticated
|
||||
)
|
||||
// If logged out, clear credentials
|
||||
_uiState.value = _uiState.value.copy(isAuthenticated = authState.isAuthenticated)
|
||||
if (!authState.isAuthenticated) {
|
||||
_uiState.value = LoginUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JS-Browser: Pending OIDC-Callback aus URL lesen (nach Keycloak-Redirect)
|
||||
viewModelScope.launch {
|
||||
val pending = consumePendingOidcCallback()
|
||||
if (pending != null) {
|
||||
handleOidcCallbackResult(pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUsername(username: String) {
|
||||
@@ -69,21 +81,18 @@ class LoginViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/** Password Grant Login (Fallback / web-app). */
|
||||
fun login() {
|
||||
val currentState = _uiState.value
|
||||
|
||||
// Validate input
|
||||
if (currentState.username.isBlank()) {
|
||||
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentState.password.isBlank()) {
|
||||
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
// Start the login process
|
||||
_uiState.value = currentState.copy(
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
@@ -97,30 +106,9 @@ class LoginViewModel(
|
||||
username = currentState.username,
|
||||
password = currentState.password
|
||||
)
|
||||
|
||||
if (loginResponse.success && loginResponse.token != null) {
|
||||
// Store the JWT token
|
||||
authTokenManager.setToken(loginResponse.token)
|
||||
|
||||
// isAuthenticated will be updated via the flow collector in init block
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = null
|
||||
)
|
||||
|
||||
// Fire-and-forget: Trigger Backend Sync so the user exists in Members
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Fire-and-forget sync call; Bearer token added by Ktor Auth plugin
|
||||
// IMPORTANT: Use relative path (no leading slash) so Ktor appends it to baseUrl
|
||||
// baseUrl is http://localhost:8080/api (JS) or http://localhost:8081 (JVM)
|
||||
// Result: http://localhost:8080/api/members/sync -> Proxy -> http://localhost:8081/api/members/sync
|
||||
// apiClient.post("members/sync")
|
||||
} catch (_: Exception) {
|
||||
// Non-fatal: Wir zeigen Sync-Fehler im Login nicht an
|
||||
}
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = null)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
@@ -135,4 +123,104 @@ class LoginViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den OIDC Authorization Code Flow mit PKCE für frontend-client.
|
||||
* JVM: öffnet Browser + wartet auf Callback.
|
||||
* JS: leitet die Seite zu Keycloak weiter (kehrt nicht zurück).
|
||||
*/
|
||||
fun startOidcFlow() {
|
||||
if (_uiState.value.isOidcLoading) return
|
||||
_uiState.value = _uiState.value.copy(isOidcLoading = true, errorMessage = null)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val codeVerifier = PkceHelper.generateCodeVerifier()
|
||||
val state = PkceHelper.generateState()
|
||||
val redirectUri = getOidcRedirectUri()
|
||||
|
||||
pendingCodeVerifier = codeVerifier
|
||||
pendingState = state
|
||||
|
||||
val authUrl = authApiClient.buildAuthorizationUrl(
|
||||
redirectUri = redirectUri,
|
||||
codeVerifier = codeVerifier,
|
||||
state = state,
|
||||
scopes = AppConstants.OIDC_SCOPES
|
||||
)
|
||||
|
||||
val result = launchOidcFlow(authUrl, AppConstants.OIDC_CALLBACK_PORT)
|
||||
handleOidcCallbackResult(result)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isOidcLoading = false,
|
||||
errorMessage = "OIDC-Flow fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Verarbeitet das Ergebnis des OIDC-Callbacks (intern + von JS-Startup). */
|
||||
private suspend fun handleOidcCallbackResult(result: OidcCallbackResult) {
|
||||
when (result) {
|
||||
is OidcCallbackResult.Success -> {
|
||||
val verifier = pendingCodeVerifier
|
||||
val expectedState = pendingState
|
||||
|
||||
if (verifier == null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isOidcLoading = false,
|
||||
errorMessage = "OIDC-Fehler: Kein Code Verifier gefunden. Bitte erneut anmelden."
|
||||
)
|
||||
return
|
||||
}
|
||||
if (expectedState != null && expectedState != result.state) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isOidcLoading = false,
|
||||
errorMessage = "OIDC-Sicherheitsfehler: State stimmt nicht überein."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val redirectUri = getOidcRedirectUri()
|
||||
val tokenResponse = authApiClient.exchangeCodeForToken(
|
||||
code = result.code,
|
||||
codeVerifier = verifier,
|
||||
redirectUri = redirectUri
|
||||
)
|
||||
|
||||
pendingCodeVerifier = null
|
||||
pendingState = null
|
||||
|
||||
if (tokenResponse.success && tokenResponse.token != null) {
|
||||
authTokenManager.setToken(tokenResponse.token)
|
||||
_uiState.value = _uiState.value.copy(isOidcLoading = false, errorMessage = null)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isOidcLoading = false,
|
||||
errorMessage = tokenResponse.message ?: "Token-Austausch fehlgeschlagen"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is OidcCallbackResult.Error -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isOidcLoading = false,
|
||||
errorMessage = "Keycloak-Fehler: ${result.error}" +
|
||||
(result.description?.let { " — $it" } ?: "")
|
||||
)
|
||||
}
|
||||
|
||||
OidcCallbackResult.Timeout -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isOidcLoading = false,
|
||||
errorMessage = "Timeout: Anmeldung wurde nicht abgeschlossen."
|
||||
)
|
||||
}
|
||||
|
||||
OidcCallbackResult.Redirecting -> {
|
||||
// JS: Seite wird weitergeleitet — isOidcLoading bleibt true (Spinner sichtbar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package at.mocode.frontend.core.auth.data
|
||||
|
||||
import kotlinx.browser.window
|
||||
|
||||
/**
|
||||
* JS-Implementierung: leitet die gesamte Seite zu Keycloak weiter.
|
||||
* Die Funktion kehrt nicht zurück —
|
||||
* der Callback wird beim nächsten App-Start via [consumePendingOidcCallback] verarbeitet.
|
||||
*/
|
||||
actual suspend fun launchOidcFlow(
|
||||
authUrl: String,
|
||||
callbackPort: Int // wird im Browser ignoriert (kein lokaler Server)
|
||||
): OidcCallbackResult {
|
||||
window.location.href = authUrl
|
||||
// Diese Zeile wird nie erreicht — der Browser leitet weiter
|
||||
return OidcCallbackResult.Redirecting
|
||||
}
|
||||
|
||||
/**
|
||||
* JS-Implementierung: prüft beim App-Start, ob die aktuelle URL einen
|
||||
* OIDC-Callback enthält (code= und state= Parameter von Keycloak).
|
||||
* Bereinigt nach dem Auslesen die URL via replaceState.
|
||||
*/
|
||||
actual fun consumePendingOidcCallback(): OidcCallbackResult? {
|
||||
val search = window.location.search
|
||||
if (!search.contains("code=")) return null
|
||||
|
||||
val params = parseJsQueryParams(search.removePrefix("?"))
|
||||
val code = params["code"] ?: return null
|
||||
val state = params["state"] ?: return null
|
||||
val error = params["error"]
|
||||
|
||||
// URL bereinigen — Code soll nicht im Browser-Verlauf bleiben
|
||||
try {
|
||||
window.history.replaceState(null, "", window.location.pathname)
|
||||
} catch (_: Throwable) {
|
||||
// ignore — kein kritischer Fehler
|
||||
}
|
||||
|
||||
return if (error != null) {
|
||||
OidcCallbackResult.Error(
|
||||
error = error,
|
||||
description = params["error_description"]
|
||||
)
|
||||
} else {
|
||||
OidcCallbackResult.Success(code = code, state = state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsQueryParams(query: String): Map<String, String> =
|
||||
query.split("&")
|
||||
.filter { it.contains("=") }
|
||||
.associate {
|
||||
val parts = it.split("=", limit = 2)
|
||||
parts[0] to decodeURIComponent(parts.getOrElse(1) { "" })
|
||||
}
|
||||
|
||||
actual fun getOidcRedirectUri(): String {
|
||||
val origin = try {
|
||||
window.location.origin
|
||||
} catch (_: Throwable) {
|
||||
"http://localhost"
|
||||
}
|
||||
return origin + at.mocode.frontend.core.domain.AppConstants.OIDC_REDIRECT_URI_JS_PATH
|
||||
}
|
||||
|
||||
private fun decodeURIComponent(encoded: String): String =
|
||||
js("decodeURIComponent(encoded)").unsafeCast<String>()
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package at.mocode.frontend.core.auth.data
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.awt.Desktop
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* JVM-Implementierung: öffnet den System-Browser und startet einen lokalen
|
||||
* HTTP-Callback-Server. Wartet maximal 5 Minuten auf den Keycloak-Redirect.
|
||||
*/
|
||||
actual suspend fun launchOidcFlow(
|
||||
authUrl: String,
|
||||
callbackPort: Int
|
||||
): OidcCallbackResult {
|
||||
val deferred = CompletableDeferred<OidcCallbackResult>()
|
||||
|
||||
val server = HttpServer.create(InetSocketAddress("localhost", callbackPort), 0)
|
||||
server.createContext("/callback") { exchange ->
|
||||
val query = exchange.requestURI.query ?: ""
|
||||
val params = parseQueryParams(query)
|
||||
|
||||
val result = when {
|
||||
params["error"] != null -> OidcCallbackResult.Error(
|
||||
error = params["error"]!!,
|
||||
description = params["error_description"]
|
||||
)
|
||||
|
||||
params["code"] != null && params["state"] != null -> OidcCallbackResult.Success(
|
||||
code = params["code"]!!,
|
||||
state = params["state"]!!
|
||||
)
|
||||
|
||||
else -> OidcCallbackResult.Error("invalid_callback", "Fehlende code oder state Parameter")
|
||||
}
|
||||
|
||||
val html = when (result) {
|
||||
is OidcCallbackResult.Success ->
|
||||
"<html><body style='font-family:sans-serif;text-align:center;padding:40px'>" +
|
||||
"<h2>✅ Anmeldung erfolgreich!</h2>" +
|
||||
"<p>Dieses Fenster kann geschlossen werden.</p></body></html>"
|
||||
|
||||
else ->
|
||||
"<html><body style='font-family:sans-serif;text-align:center;padding:40px'>" +
|
||||
"<h2>❌ Anmeldung fehlgeschlagen</h2>" +
|
||||
"<p>${(result as? OidcCallbackResult.Error)?.error}</p></body></html>"
|
||||
}
|
||||
|
||||
val responseBytes = html.toByteArray()
|
||||
exchange.sendResponseHeaders(200, responseBytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(responseBytes) }
|
||||
|
||||
server.stop(0)
|
||||
deferred.complete(result)
|
||||
}
|
||||
server.executor = null
|
||||
server.start()
|
||||
|
||||
// Browser öffnen
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
Desktop.getDesktop().browse(URI(authUrl))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
server.stop(0)
|
||||
return OidcCallbackResult.Error("browser_error", "Browser konnte nicht geöffnet werden: ${e.message}")
|
||||
}
|
||||
|
||||
// Warten auf Callback (max. 5 Minuten)
|
||||
return withTimeoutOrNull(5 * 60 * 1000L) {
|
||||
deferred.await()
|
||||
} ?: run {
|
||||
server.stop(0)
|
||||
OidcCallbackResult.Timeout
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf JVM: kein URL-basierter Pending-Callback (Desktop-App startet frisch).
|
||||
*/
|
||||
actual fun consumePendingOidcCallback(): OidcCallbackResult? = null
|
||||
|
||||
actual fun getOidcRedirectUri(): String = at.mocode.frontend.core.domain.AppConstants.OIDC_REDIRECT_URI_JVM
|
||||
|
||||
private fun parseQueryParams(query: String): Map<String, String> =
|
||||
query.split("&")
|
||||
.filter { it.contains("=") }
|
||||
.associate {
|
||||
val (k, v) = it.split("=", limit = 2)
|
||||
k to java.net.URLDecoder.decode(v, "UTF-8")
|
||||
}
|
||||
+14
-3
@@ -1,11 +1,22 @@
|
||||
package at.mocode.frontend.core.domain
|
||||
|
||||
object AppConstants {
|
||||
// Keycloak Configuration
|
||||
// Note: These defaults are for local development.
|
||||
// In production, these should be provided via build config or environment variables.
|
||||
const val KEYCLOAK_URL = "http://localhost:8180"
|
||||
const val KEYCLOAK_REALM = "meldestelle"
|
||||
const val KEYCLOAK_CLIENT_ID = "web-app"
|
||||
const val KEYCLOAK_CLIENT_SECRET = "" // Public client usually has no secret
|
||||
|
||||
// Native/Desktop KMP client (PKCE Authorization Code Flow, kein Secret)
|
||||
const val KEYCLOAK_CLIENT_ID = "frontend-client"
|
||||
const val KEYCLOAK_CLIENT_SECRET = "" // Public client — kein Secret
|
||||
|
||||
// OIDC Redirect URI für JVM Desktop (loopback callback server)
|
||||
const val OIDC_CALLBACK_PORT = 18080
|
||||
const val OIDC_REDIRECT_URI_JVM = "http://localhost:$OIDC_CALLBACK_PORT/callback"
|
||||
|
||||
// OIDC Redirect URI für JS/Browser (gleiche Origin, Route /auth/callback)
|
||||
const val OIDC_REDIRECT_URI_JS_PATH = "/auth/callback"
|
||||
|
||||
// OIDC Scopes
|
||||
const val OIDC_SCOPES = "openid profile email"
|
||||
}
|
||||
|
||||
+5
-9
@@ -1,13 +1,9 @@
|
||||
package at.mocode.ping.feature.data
|
||||
|
||||
import at.mocode.ping.api.EnhancedPingResponse
|
||||
import at.mocode.ping.api.HealthResponse
|
||||
import at.mocode.ping.api.PingApi
|
||||
import at.mocode.ping.api.PingEvent
|
||||
import at.mocode.ping.api.PingResponse
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import at.mocode.ping.api.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
|
||||
/**
|
||||
* PingApi-Implementierung, die einen bereitgestellten HttpClient verwendet (z. B. den per Dependency Injection
|
||||
@@ -39,7 +35,7 @@ class PingApiKoinClient(private val client: HttpClient) : PingApi {
|
||||
|
||||
override suspend fun syncPings(since: Long): List<PingEvent> {
|
||||
return client.get("/api/ping/sync") {
|
||||
url.parameters.append("lastSyncTimestamp", since.toString())
|
||||
url.parameters.append("since", since.toString())
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user