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
|
SPRING_DATA_VALKEY_PASSWORD=valkey-password
|
||||||
|
|
||||||
# --- KEYCLOAK ---
|
# --- KEYCLOAK ---
|
||||||
KEYCLOAK_IMAGE_TAG=26.4
|
KEYCLOAK_IMAGE_TAG=26.5.5
|
||||||
KC_HEAP_MIN=512M
|
KC_HEAP_MIN=512M
|
||||||
KC_HEAP_MAX=1024M
|
KC_HEAP_MAX=1024M
|
||||||
# Lokale Entwicklung: start-dev (kein Pre-Build nötig, kein --optimized)
|
# Lokale Entwicklung: start-dev (kein Pre-Build nötig, kein --optimized)
|
||||||
# Server/Produktion: start --optimized --import-realm (nutzt das pre-built Registry-Image)
|
# 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_USERNAME=kc-admin
|
||||||
KC_ADMIN_PASSWORD=kc-password
|
KC_ADMIN_PASSWORD=kc-password
|
||||||
KC_DB=postgres
|
KC_DB=postgres
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
name: PR Guard
|
name: PR Guard
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "**" ]
|
branches: [ "**" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
no-hardcoded-versions:
|
no-hardcoded-versions:
|
||||||
runs-on: ubuntu-latest
|
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 ===
|
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||||
ARG GRADLE_VERSION
|
ARG GRADLE_VERSION=9.3.1
|
||||||
ARG JAVA_VERSION
|
ARG JAVA_VERSION=25
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ dependencies {
|
|||||||
implementation(libs.spring.cloud.starter.circuitbreaker.reactor.resilience4j)
|
implementation(libs.spring.cloud.starter.circuitbreaker.reactor.resilience4j)
|
||||||
|
|
||||||
implementation(libs.spring.data.valkey)
|
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)
|
testImplementation(projects.platform.platformTesting)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ spring:
|
|||||||
autoconfigure:
|
autoconfigure:
|
||||||
exclude:
|
exclude:
|
||||||
- "org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration"
|
- "org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration"
|
||||||
# --- VALKEY (für Rate Limiting) ---
|
# --- VALKEY (Redis-protokollkompatibel, Spring nutzt spring.data.redis.*) ---
|
||||||
data:
|
data:
|
||||||
valkey:
|
redis:
|
||||||
host: ${SPRING_DATA_VALKEY_HOST:localhost}
|
host: ${SPRING_DATA_VALKEY_HOST:localhost}
|
||||||
port: ${SPRING_DATA_VALKEY_PORT:6379}
|
port: ${SPRING_DATA_VALKEY_PORT:6379}
|
||||||
password: ${SPRING_DATA_VALKEY_PASSWORD:valkey-password}
|
password: ${SPRING_DATA_VALKEY_PASSWORD:}
|
||||||
|
|
||||||
# --- CONSUL (Service Discovery) ---
|
# --- CONSUL (Service Discovery) ---
|
||||||
cloud:
|
cloud:
|
||||||
@@ -52,9 +52,15 @@ management:
|
|||||||
enabled: true # Aktiviert /actuator/health/liveness und /readiness
|
enabled: true # Aktiviert /actuator/health/liveness und /readiness
|
||||||
tracing:
|
tracing:
|
||||||
sampling:
|
sampling:
|
||||||
probability: 1.0
|
probability: ${TRACING_SAMPLING_PROBABILITY:1.0}
|
||||||
propagation:
|
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 ---
|
# --- Custom Service URLs ---
|
||||||
# Default: Localhost (für Entwicklung ohne Docker)
|
# Default: Localhost (für Entwicklung ohne Docker)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
||||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||||
ARG GRADLE_VERSION
|
ARG GRADLE_VERSION=9.3.1
|
||||||
ARG JAVA_VERSION
|
ARG JAVA_VERSION=25
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ spring:
|
|||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
|
|
||||||
# --- REDIS KONFIGURATION ---
|
# --- VALKEY KONFIGURATION (Redis-protokollkompatibel, Spring nutzt spring.data.redis.*) ---
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${SPRING_DATA_REDIS_HOST:localhost}
|
host: ${SPRING_DATA_VALKEY_HOST:localhost}
|
||||||
port: ${SPRING_DATA_REDIS_PORT:6379}
|
port: ${SPRING_DATA_VALKEY_PORT:6379}
|
||||||
password: ${SPRING_DATA_REDIS_PASSWORD:redis-password}
|
password: ${SPRING_DATA_VALKEY_PASSWORD:}
|
||||||
connect-timeout: 5s
|
connect-timeout: 5s
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Production-Ready Keycloak Dockerfile
|
# Production-Ready Keycloak Dockerfile
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Based on: quay.io/keycloak/keycloak:26.4
|
# Based on: quay.io/keycloak/keycloak:26.5.5
|
||||||
# Features:
|
# Features:
|
||||||
# - Pre-built optimized image (faster startup)
|
# - Pre-built optimized image (faster startup)
|
||||||
# - Security hardening
|
# - Security hardening
|
||||||
# - Health monitoring
|
# - Health monitoring
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
ARG KEYCLOAK_IMAGE_TAG
|
ARG KEYCLOAK_IMAGE_TAG=26.5.5
|
||||||
|
|
||||||
FROM quay.io/keycloak/keycloak:${KEYCLOAK_IMAGE_TAG}
|
FROM quay.io/keycloak/keycloak:${KEYCLOAK_IMAGE_TAG}
|
||||||
|
|
||||||
|
ARG KEYCLOAK_IMAGE_TAG=26.5.5
|
||||||
|
|
||||||
LABEL maintainer="Meldestelle Development Team"
|
LABEL maintainer="Meldestelle Development Team"
|
||||||
LABEL description="Production-ready Keycloak for Meldestelle authentication"
|
LABEL description="Production-ready Keycloak for Meldestelle authentication"
|
||||||
LABEL version="${KEYCLOAK_IMAGE_TAG}"
|
LABEL version="${KEYCLOAK_IMAGE_TAG}"
|
||||||
|
|||||||
@@ -93,8 +93,7 @@
|
|||||||
"http://localhost:8081",
|
"http://localhost:8081",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://app.meldestelle.at",
|
"https://app.meldestelle.at",
|
||||||
"http://localhost:8080",
|
"http://localhost:8080"
|
||||||
"*"
|
|
||||||
],
|
],
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"bearerOnly": false,
|
"bearerOnly": false,
|
||||||
@@ -111,7 +110,8 @@
|
|||||||
"client.secret.creation.time": "0",
|
"client.secret.creation.time": "0",
|
||||||
"oauth2.device.authorization.grant.enabled": "false",
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
"backchannel.logout.session.required": "true",
|
"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": [
|
"protocolMappers": [
|
||||||
{
|
{
|
||||||
@@ -202,12 +202,12 @@
|
|||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"http://localhost:4000",
|
"http://localhost:4000",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://app.meldestelle.at",
|
"https://app.meldestelle.at"
|
||||||
"*"
|
|
||||||
],
|
],
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"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,
|
"directAccessGrantsEnabled": true,
|
||||||
"serviceAccountsEnabled": false,
|
"serviceAccountsEnabled": false,
|
||||||
"fullScopeAllowed": true
|
"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": [
|
"users": [
|
||||||
@@ -242,7 +283,7 @@
|
|||||||
"credentials": [
|
"credentials": [
|
||||||
{
|
{
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"value": "password",
|
"value": "Admin#1234",
|
||||||
"temporary": false
|
"temporary": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -266,7 +307,7 @@
|
|||||||
"requiredCredentials": [
|
"requiredCredentials": [
|
||||||
"password"
|
"password"
|
||||||
],
|
],
|
||||||
"passwordPolicy": "length(8)",
|
"passwordPolicy": "length(10) and digits(1) and upperCase(1) and specialChars(1) and notUsername(undefined)",
|
||||||
"otpPolicyType": "totp",
|
"otpPolicyType": "totp",
|
||||||
"otpPolicyAlgorithm": "HmacSHA1",
|
"otpPolicyAlgorithm": "HmacSHA1",
|
||||||
"otpPolicyInitialCounter": 0,
|
"otpPolicyInitialCounter": 0,
|
||||||
|
|||||||
+12
-8
@@ -76,8 +76,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: config/docker/keycloak/Dockerfile
|
dockerfile: config/docker/keycloak/Dockerfile
|
||||||
args:
|
args:
|
||||||
KEYCLOAK_IMAGE_TAG: "${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.4}"
|
image: "${DOCKER_REGISTRY:-git.mo-code.at/mocode-software}/keycloak:${KEYCLOAK_IMAGE_TAG:-26.5.5}"
|
||||||
container_name: "${PROJECT_NAME:-meldestelle}-keycloak"
|
container_name: "${PROJECT_NAME:-meldestelle}-keycloak"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: [ "infra", "all" ]
|
profiles: [ "infra", "all" ]
|
||||||
@@ -93,8 +93,8 @@ services:
|
|||||||
KC_HOSTNAME: "${KC_HOSTNAME:-localhost}"
|
KC_HOSTNAME: "${KC_HOSTNAME:-localhost}"
|
||||||
# WICHTIG: false erlaubt Zugriff über beliebige Hostnamen (nötig für Server-Betrieb ohne TLS)
|
# WICHTIG: false erlaubt Zugriff über beliebige Hostnamen (nötig für Server-Betrieb ohne TLS)
|
||||||
KC_HOSTNAME_STRICT: "${KC_HOSTNAME_STRICT:-false}"
|
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 wurde entfernt — deprecated v1-Option in Keycloak 26.x (hostname v2).
|
||||||
KC_HOSTNAME_STRICT_HTTPS: "${KC_HOSTNAME_STRICT_HTTPS:-false}"
|
# HTTP-Zugriff wird ausschließlich über KC_HTTP_ENABLED gesteuert.
|
||||||
KC_HTTP_ENABLED: "true"
|
KC_HTTP_ENABLED: "true"
|
||||||
# Admin-Interface explizit auf allen Interfaces binden (0.0.0.0)
|
# Admin-Interface explizit auf allen Interfaces binden (0.0.0.0)
|
||||||
KC_HTTP_MANAGEMENT_PORT: "9000"
|
KC_HTTP_MANAGEMENT_PORT: "9000"
|
||||||
@@ -109,11 +109,15 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: "service_healthy"
|
condition: "service_healthy"
|
||||||
healthcheck:
|
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"
|
interval: "15s"
|
||||||
timeout: "5s"
|
timeout: "10s"
|
||||||
retries: "10"
|
retries: 5
|
||||||
start_period: "60s"
|
start_period: "90s"
|
||||||
volumes:
|
volumes:
|
||||||
- "./config/docker/keycloak:/opt/keycloak/data/import:Z"
|
- "./config/docker/keycloak:/opt/keycloak/data/import:Z"
|
||||||
# start --optimized nutzt das pre-built Image (kc.sh build im Dockerfile)
|
# start --optimized nutzt das pre-built Image (kc.sh build im Dockerfile)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: Roadmap
|
type: Roadmap
|
||||||
status: ACTIVE
|
status: ACTIVE
|
||||||
owner: Lead Architect
|
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"
|
# 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.
|
* [x] **Persistence:** Flyway & Postgres Integration stabil.
|
||||||
|
|
||||||
#### 🏗️ Agent: Infrastructure & DevOps
|
#### 🏗️ 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.
|
* [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] **HTTP Client Core:** Ktor Client mit Auth & Error Handling.
|
||||||
* [x] **Authentication Flow:** OIDC Login Flow (Keycloak) implementiert.
|
* [x] **Authentication Flow:** OIDC Login Flow (Keycloak) implementiert.
|
||||||
* [x] **UI Implementation:** Debug-Screen für Pings vorhanden.
|
* [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.
|
5. [x] In Zipkin ist der komplette Request-Trace (Frontend -> Gateway -> Service -> DB) sichtbar.
|
||||||
|
|
||||||
## 4. Next Steps (Q1/2026)
|
## 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)
|
3. **Reporting / Printing:** (Vorgemerkt)
|
||||||
* Anforderung: PDF-Generierung für Startlisten, Ergebnislisten, Dressur-Protokolle (personalisiert).
|
* Anforderung: PDF-Generierung für Startlisten, Ergebnislisten, Dressur-Protokolle (personalisiert).
|
||||||
* Architektur-Entscheidung: Dezentraler Microservice (wegen Resource-Bursts).
|
* Architektur-Entscheidung: Dezentraler Microservice (wegen Resource-Bursts).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: Roadmap
|
type: Roadmap
|
||||||
status: ARCHIVED
|
status: ARCHIVED
|
||||||
owner: Lead Architect
|
owner: Lead Architect
|
||||||
last_update: 2026-01-15
|
last_update: 2026-03-09
|
||||||
---
|
---
|
||||||
|
|
||||||
# Roadmap Q1 2026: "Operation Tracer Bullet"
|
# 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.
|
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)
|
### 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`.
|
- [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.
|
- [ ] **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)_
|
||||||
- [ ] **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).
|
- [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)_
|
||||||
- [ ] **Micrometer Upgrade:** Explizites Setzen von Micrometer `1.16.1` für besseren Java 25 Support.
|
- [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)
|
### 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.
|
- [x] **Gateway CircuitBreaker:** Behebung des `ClassNotFoundException` / `NoSuchMethodError` im Gateway (vermutlich
|
||||||
- [ ] **Keycloak Config:** Validierung der Realm-Konfiguration (`meldestelle`) und der Client-Scopes für den `ping-service`.
|
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.
|
Implementierung des ersten vertikalen Durchstichs.
|
||||||
|
|
||||||
### 2.1 Modul-Struktur & API (Backend Dev)
|
### 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.
|
- [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)_
|
||||||
- [ ] **API Definition:** Erstellung von `:contracts:ping-api` mit KMP-kompatiblen DTOs (`PingResponse`).
|
- [x] **API Definition:** Erstellung von `:contracts:ping-api` mit KMP-kompatiblen DTOs (`PingResponse`). _(verifiziert
|
||||||
|
2026-03-09)_
|
||||||
|
|
||||||
### 2.2 Service Implementation (Backend Dev)
|
### 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.
|
- [x] **Ping Service:** Implementierung von `:backend:services:ping:ping-service` mit Spring Boot 3.5.9. _(verifiziert
|
||||||
- [ ] **Discovery:** Registrierung bei Consul.
|
2026-03-09)_
|
||||||
- [ ] **Observability:** Tracing mit Zipkin und Metrics mit Prometheus aktivieren.
|
- [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.
|
Anbindung des Frontends an den neuen Service.
|
||||||
|
|
||||||
### 3.1 HTTP Client & Sync (Frontend Expert)
|
### 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.
|
- [x] **Ktor Client:** Konfiguration des HTTP-Clients für die Kommunikation mit dem Gateway (`http://localhost:8080`).
|
||||||
- [ ] **Integration:** Aufruf von `/api/ping` und `/api/ping/secure` und Anzeige im UI.
|
_(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)
|
### 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)
|
## Phase 4: Review & Hardening (Woche 4)
|
||||||
|
|
||||||
### 4.1 Quality Assurance (QA Specialist)
|
### 4.1 Quality Assurance (QA Specialist)
|
||||||
- [ ] **Integrationstests:** Automatisierte Tests, die den gesamten Flow (Frontend -> Gateway -> Service) prüfen.
|
- [ ] **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.
|
- [ ] **Lasttests:** Einfache Lasttests auf den `ping-service` zur Validierung der Virtual Threads. _(zurückgestellt — erst bei fachlicher Implementierung)_
|
||||||
|
|
||||||
### 4.2 Documentation (Curator)
|
### 4.2 Documentation (Curator)
|
||||||
- [ ] **Update Docs:** Aktualisierung der Architektur-Dokumentation basierend auf den Erkenntnissen.
|
- [ ] **Update Docs:** Aktualisierung der Architektur-Dokumentation basierend auf den Erkenntnissen.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: Roadmap
|
type: Roadmap
|
||||||
status: ARCHIVED
|
status: ARCHIVED
|
||||||
owner: Lead Architect
|
owner: Lead Architect
|
||||||
last_update: 2026-01-15
|
last_update: 2026-03-09
|
||||||
---
|
---
|
||||||
|
|
||||||
# Roadmap: System Hardening & Stability
|
# Roadmap: System Hardening & Stability
|
||||||
@@ -15,48 +15,73 @@ last_update: 2026-01-15
|
|||||||
## 1. Backend & Build System (Architect / Backend Dev)
|
## 1. Backend & Build System (Architect / Backend Dev)
|
||||||
|
|
||||||
### 1.1 Dependency Management
|
### 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.
|
- `libs.versions.toml`: Spring Cloud Version auf `2025.0.1` setzen.
|
||||||
- `platform/build.gradle.kts`: BOM Import prüfen.
|
- `platform/build.gradle.kts`: BOM Import prüfen.
|
||||||
- Ziel: Behebung der `ClassNotFoundException` im Gateway (CircuitBreaker).
|
- 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.
|
- 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.
|
- Entscheidung: SQLDelight für KMP Client.
|
||||||
- Entfernen von Room Dependencies (falls nicht zwingend benötigt).
|
- Entfernen von Room Dependencies (falls nicht zwingend benötigt).
|
||||||
- Exposed Version im Backend prüfen (`0.5x` vs `1.0.0-rc`).
|
- 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
|
### 1.2 Modul-Struktur
|
||||||
- [ ] **`core-utils` Refactoring:**
|
- [x] **`core-utils` Refactoring:**
|
||||||
- Verschieben von `DatabaseUtils` (JVM-Code) aus `core-utils` nach `:backend:infrastructure:persistence`.
|
- Verschieben von `DatabaseUtils` (JVM-Code) aus `core-utils` nach `:backend:infrastructure:persistence`.
|
||||||
- Sicherstellen, dass `core-utils` rein `commonMain` kompatibel ist.
|
- 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. Infrastructure & DevOps (DevOps Engineer)
|
||||||
|
|
||||||
### 2.1 Docker Environment
|
### 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.
|
- Prüfen, ob wir Redis durch Valkey (Open Source Fork) ersetzen, um Lizenzprobleme zu vermeiden.
|
||||||
- Update `docker-compose.yaml`.
|
- 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).
|
- 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
|
### 2.2 Observability
|
||||||
- [ ] **Zipkin Integration:**
|
|
||||||
- Prüfen, ob Traces vom Gateway bis zur DB durchgereicht werden.
|
- [x] **Zipkin Integration:** _(verifiziert 2026-03-09)_
|
||||||
- Ggf. `micrometer-tracing-bridge-brave` konfigurieren.
|
- `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. Frontend (Frontend Expert)
|
||||||
|
|
||||||
### 3.1 Build Fixes
|
### 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.
|
- Behebung der `Unresolved reference: Worker` Fehler im `composeApp:wasmJsBrowserDistribution` Task.
|
||||||
- Prüfen der `kotlinx-browser` Version.
|
- Prüfen der `kotlinx-browser` Version.
|
||||||
|
|
||||||
### 3.2 Auth Integration
|
### 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
|
* ✅ **Gitea Secrets Finalisierung:** Erstellen der Secrets
|
||||||
* `REGISTRY_TOKEN` und
|
* `REGISTRY_TOKEN` und
|
||||||
* `REGISTRY_USER` im Repository „Meldestelle“ (Umgehung der `GITEA_`-Namenssperre).
|
* `REGISTRY_USER` im Repository „Meldestelle“ (Umgehung der `GITEA_`-Namenssperre).
|
||||||
*[ ] **Runner-Stabilisierung:**
|
*[x] **Runner-Stabilisierung:** _(verifiziert 2026-03-09: Screenshot bestätigt)_
|
||||||
* Prüfen des Systemd-Status von `act_runner` im Container `infra-runner`.
|
* `act_runner` ist `active (running)` seit 06.03.2026, `enabled; preset: enabled`.
|
||||||
*[ ] Sicherstellen, dass der Status im Gitea-Interface von „Inaktiv“ auf „Aktiv“ springt.
|
*[x] Status im Gitea-Interface: Runner ist **Aktiv** (läuft Tasks seit 06.03.2026).
|
||||||
*[ ] **Erster Test-Build:**
|
*[ ] **Erster Test-Build:**
|
||||||
* Erstellen eines Test-Projekts und
|
* Erstellen eines Test-Projekts und
|
||||||
* Push aus der IDEA auslösen und das Log-Streaming in Gitea Actions verfolgen.
|
* 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
|
@Serializable
|
||||||
private data class KeycloakTokenResponse(
|
private data class KeycloakTokenResponse(
|
||||||
@SerialName("access_token") val accessToken: String,
|
@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,
|
value = uiState.username,
|
||||||
onValueChange = viewModel::updateUsername,
|
onValueChange = viewModel::updateUsername,
|
||||||
label = { Text("Benutzername") },
|
label = { Text("Benutzername") },
|
||||||
enabled = !uiState.isLoading,
|
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||||
isError = uiState.usernameError != null,
|
isError = uiState.usernameError != null,
|
||||||
supportingText = uiState.usernameError?.let { { Text(it) } },
|
supportingText = uiState.usernameError?.let { { Text(it) } },
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -76,7 +76,7 @@ fun LoginScreen(
|
|||||||
value = uiState.password,
|
value = uiState.password,
|
||||||
onValueChange = viewModel::updatePassword,
|
onValueChange = viewModel::updatePassword,
|
||||||
label = { Text("Passwort") },
|
label = { Text("Passwort") },
|
||||||
enabled = !uiState.isLoading,
|
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||||
isError = uiState.passwordError != null,
|
isError = uiState.passwordError != null,
|
||||||
supportingText = uiState.passwordError?.let { { Text(it) } },
|
supportingText = uiState.passwordError?.let { { Text(it) } },
|
||||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
@@ -147,6 +147,42 @@ fun LoginScreen(
|
|||||||
Text("Anmelden")
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import at.mocode.frontend.core.auth.data.AuthApiClient
|
import at.mocode.frontend.core.auth.data.*
|
||||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
import at.mocode.frontend.core.domain.AppConstants
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -17,17 +17,20 @@ data class LoginUiState(
|
|||||||
val username: String = "",
|
val username: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isOidcLoading: Boolean = false,
|
||||||
val isAuthenticated: Boolean = false,
|
val isAuthenticated: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val usernameError: String? = null,
|
val usernameError: String? = null,
|
||||||
val passwordError: String? = null
|
val passwordError: String? = null
|
||||||
) {
|
) {
|
||||||
val canLogin: Boolean
|
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(
|
class LoginViewModel(
|
||||||
private val authTokenManager: AuthTokenManager,
|
private val authTokenManager: AuthTokenManager,
|
||||||
@@ -38,19 +41,28 @@ class LoginViewModel(
|
|||||||
private val _uiState = MutableStateFlow(LoginUiState())
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
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 {
|
init {
|
||||||
// Observe AuthTokenManager state to keep UI in sync
|
// AuthTokenManager-State beobachten → UI synchron halten
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authTokenManager.authState.collect { authState ->
|
authTokenManager.authState.collect { authState ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(isAuthenticated = authState.isAuthenticated)
|
||||||
isAuthenticated = authState.isAuthenticated
|
|
||||||
)
|
|
||||||
// If logged out, clear credentials
|
|
||||||
if (!authState.isAuthenticated) {
|
if (!authState.isAuthenticated) {
|
||||||
_uiState.value = LoginUiState()
|
_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) {
|
fun updateUsername(username: String) {
|
||||||
@@ -69,21 +81,18 @@ class LoginViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Password Grant Login (Fallback / web-app). */
|
||||||
fun login() {
|
fun login() {
|
||||||
val currentState = _uiState.value
|
val currentState = _uiState.value
|
||||||
|
|
||||||
// Validate input
|
|
||||||
if (currentState.username.isBlank()) {
|
if (currentState.username.isBlank()) {
|
||||||
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
|
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentState.password.isBlank()) {
|
if (currentState.password.isBlank()) {
|
||||||
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
|
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the login process
|
|
||||||
_uiState.value = currentState.copy(
|
_uiState.value = currentState.copy(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
@@ -97,30 +106,9 @@ class LoginViewModel(
|
|||||||
username = currentState.username,
|
username = currentState.username,
|
||||||
password = currentState.password
|
password = currentState.password
|
||||||
)
|
)
|
||||||
|
|
||||||
if (loginResponse.success && loginResponse.token != null) {
|
if (loginResponse.success && loginResponse.token != null) {
|
||||||
// Store the JWT token
|
|
||||||
authTokenManager.setToken(loginResponse.token)
|
authTokenManager.setToken(loginResponse.token)
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = null)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
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
|
package at.mocode.frontend.core.domain
|
||||||
|
|
||||||
object AppConstants {
|
object AppConstants {
|
||||||
// Keycloak Configuration
|
// Keycloak Configuration
|
||||||
// Note: These defaults are for local development.
|
// Note: These defaults are for local development.
|
||||||
// In production, these should be provided via build config or environment variables.
|
// In production, these should be provided via build config or environment variables.
|
||||||
const val KEYCLOAK_URL = "http://localhost:8180"
|
const val KEYCLOAK_URL = "http://localhost:8180"
|
||||||
const val KEYCLOAK_REALM = "meldestelle"
|
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
|
package at.mocode.ping.feature.data
|
||||||
|
|
||||||
import at.mocode.ping.api.EnhancedPingResponse
|
import at.mocode.ping.api.*
|
||||||
import at.mocode.ping.api.HealthResponse
|
import io.ktor.client.*
|
||||||
import at.mocode.ping.api.PingApi
|
import io.ktor.client.call.*
|
||||||
import at.mocode.ping.api.PingEvent
|
import io.ktor.client.request.*
|
||||||
import at.mocode.ping.api.PingResponse
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PingApi-Implementierung, die einen bereitgestellten HttpClient verwendet (z. B. den per Dependency Injection
|
* 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> {
|
override suspend fun syncPings(since: Long): List<PingEvent> {
|
||||||
return client.get("/api/ping/sync") {
|
return client.get("/api/ping/sync") {
|
||||||
url.parameters.append("lastSyncTimestamp", since.toString())
|
url.parameters.append("since", since.toString())
|
||||||
}.body()
|
}.body()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user