M0: Skelett + Auth-Grundgerüst (Keycloak-OIDC + API-Token)
- Symfony 7.4 LTS, PHP 8.4, FrankenPHP, SQLite (3-Layer src/UI|Logic|Data) - Auth: manueller OidcClient (Authorization-Code) im UI-Layer, kein Security-Bundle/league; Session-Gate via SessionAuthSubscriber - API-Guard via statischem X-Api-Token (ApiTokenSubscriber); ExceptionSubscriber - UserEntity (Auto-Provision) + Init-Migration - Docker (php:8.4-frankenphp) + compose (essen.ljh31.de, traefik-net external) - SmokeTests (7) grün: Redirect-Fälle, API-Token, Auth-Render, Logout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
var
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.local.*
|
||||||
|
vendor
|
||||||
|
tests/tmp
|
||||||
|
*.phpunit.result.cache
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[{compose.yaml,compose.*.yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Referenzwerte für die Produktion (docker1: /root/stacks/svc-essensplanung/.env,
|
||||||
|
# als env_file über compose gemountet – siehe Doku/plan.md M5).
|
||||||
|
# Lokale Entwicklung nutzt stattdessen .env (var/data.db).
|
||||||
|
APP_ENV=prod
|
||||||
|
APP_DEBUG=0
|
||||||
|
APP_SECRET=<random-64-hex>
|
||||||
|
DATABASE_URL=sqlite:////data/essens.db
|
||||||
|
KEYCLOAK_ISSUER=https://iam.ljh31.de/realms/family
|
||||||
|
KEYCLOAK_CLIENT_ID=essens
|
||||||
|
KEYCLOAK_CLIENT_SECRET=<keycloak-client-secret>
|
||||||
|
KEYCLOAK_REDIRECT_URI=https://essen.ljh31.de/auth/callback
|
||||||
|
API_TOKEN=<generierter-statischer-token>
|
||||||
|
# (API_TOKEN leer = /api/* ohne Zugriff; HA-Consumer bekommt den Token)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# define your env variables for the test env here
|
||||||
|
KERNEL_CLASS='App\Kernel'
|
||||||
|
APP_SECRET='$ecretf0rt3st'
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
/.env.local
|
||||||
|
/.env.local.php
|
||||||
|
/.env.*.local
|
||||||
|
/.env
|
||||||
|
/.env.dev
|
||||||
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
|
/public/bundles/
|
||||||
|
/var/
|
||||||
|
/vendor/
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> phpunit/phpunit ###
|
||||||
|
/phpunit.xml
|
||||||
|
/.phpunit.cache/
|
||||||
|
.phpunit.result.cache
|
||||||
|
###< phpunit/phpunit ###
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM php:8.4-frankenphp
|
||||||
|
|
||||||
|
ENV APP_ENV=prod \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git unzip sqlite3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN docker-php-ext-install pdo pdo_sqlite opcache bcmath intl
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
# composer.json zuerst → Layer-Cache
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN php bin/console cache:warmup --no-interaction || \
|
||||||
|
(mkdir -p var/log var/cache prod && chmod -R 777 var)
|
||||||
|
|
||||||
|
EXPOSE 8787
|
||||||
@@ -1,20 +1,35 @@
|
|||||||
# 003 – Single-User, keine Authentifizierung
|
# 003 – Auth: Keycloak-OIDC (Web) + statischer API-Token (HA)
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted (überarbeitet 2026-09-13; ersetzt „kein Login")
|
||||||
|
|
||||||
**Kontext.** Das Werkzeug wird im privaten Homelab von **einer** Person (bzw.
|
**Kontext.** Das Werkzeug wird im privaten Homelab von **einer** Person (bzw.
|
||||||
Familie via LAN) genutzt. Ein Login-Flow wird zur Barriere, nicht zum Feature.
|
Familie via LAN) genutzt. Es gibt bereits eine Keycloak-Instanz
|
||||||
Später soll Home-Assistant die API konsumieren, und dort ist es einfacher,
|
(`https://iam.ljh31.de`, Realm `family`), die von `open-webui`/`webuix` genutzt
|
||||||
einen Shared-Token zu haben statt ein OIDC-Realm zu betreiben.
|
wird – eine Wiederverwendung ist kostenlos. Home-Assistant soll zusätzlich die
|
||||||
|
API (`GET /api/today`) konsumieren; ein zweiter, stateless Auth-Mechanismus ist
|
||||||
|
für Consumer wie HA robuster als eine Browser-Session.
|
||||||
|
|
||||||
**Entscheidung.** Im MVP **kein Login**. Die API ist hinter dem bestehenden
|
**Entscheidung.**
|
||||||
Traefik-Routing `essen.ljh31.de` (ACME/TLS) erreichbar; wer die Domain kennt,
|
1. **Web-UI:** Keycloak-OIDC (Authorization-Code) gegen Realm `family`,
|
||||||
kann aufrufen. Optional (Phase 2): **Shared-API-Token** per Header
|
eigener Client `essens` (Redirect `https://essen.ljh31.de/auth/callback`).
|
||||||
(`X-Api-Token`) für externe Clients wie HA.
|
Verifiziert wird das Id-Token (RS256 + JWKS); pro Login wird eine lokale
|
||||||
|
User-Zeile auto-provisioniert (`essens_user`, gekoppelt an `sub`).
|
||||||
|
Die App selbst ist ein einfaches **Session-Auth-Gate**
|
||||||
|
(`SessionAuthSubscriber`): Session-Key `auth_sub` fehlt → Redirect `/login`.
|
||||||
|
Kein Symfony-Security-Authenticator, keine Rollen.
|
||||||
|
2. **API:** optionaler statischer Token per Header `X-Api-Token`
|
||||||
|
(env `API_TOKEN`), geprüft durch `ApiTokenSubscriber` (nur `/api/*`).
|
||||||
|
Token leer/leer-String → API blockiert; HA bekommt den Token direkt
|
||||||
|
(kein Keycloak-Refresh-Flow im Consumer).
|
||||||
|
|
||||||
**Konsequenzen.**
|
**Konsequenzen.**
|
||||||
- + Einfachster möglicher Pfad, kein Auth-State, kein JWT.
|
- + Wiederverwendung vorhandener IdP, keine zweite Auth-Welt.
|
||||||
- + HA-Integration ist trivial (einfacher Token statt OIDC).
|
- + HA-Integration bleibt trivial (ein Header statt OIDC im Sensor).
|
||||||
- − Kein Benutzer-Abgrenzen, keine Rechte. Im aktuellen Kontext kein Risiko
|
- − Manueller OIDC-Client (JWKS/Id-Token) statt Framework-Lib – bewusst:
|
||||||
(LAN + eigene Domain).
|
kein `league/oauth2-client`/Security-Bundle im Stack, Code bleibt klein
|
||||||
- − Wenn es irgendwann öffentliches Hosting wird, ADR-003 ersetzen.
|
und auditierbar.
|
||||||
|
- − Zwei Auth-Pfade (Session + Token) – klar getrennt nach Pfad-/Header-Gate.
|
||||||
|
|
||||||
|
**Abgelöst.** Fassung „Single-User, keine Authentifizierung" (M0-Start) –
|
||||||
|
Login via eigener Domain ohne Identität war mit HA-Kopplung und
|
||||||
|
Familien-Nutzung nicht mehr akzeptabel.
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ Wenn sich etwas ändert: **neue** ADR, alte als `superseded` markieren.
|
|||||||
|----|-------|--------|
|
|----|-------|--------|
|
||||||
| [001](001-backend-symfony.md) | Backend auf Symfony 7 (PHP) | accepted |
|
| [001](001-backend-symfony.md) | Backend auf Symfony 7 (PHP) | accepted |
|
||||||
| [002](002-frontend-htmx.md) | Frontend: serverseitig + htmx (kein React) | accepted |
|
| [002](002-frontend-htmx.md) | Frontend: serverseitig + htmx (kein React) | accepted |
|
||||||
| [003](003-single-user-mit-token.md) | Single-User, keine Authentifizierung (+ optionales API-Token) | accepted |
|
| [003](003-single-user-mit-token.md) | Auth: Keycloak-OIDC (Web) + statischer API-Token (HA) | accepted |
|
||||||
| [004](004-ha-consumer.md) | Home-Assistant als Consumer, nicht als Host | accepted |
|
| [004](004-ha-consumer.md) | Home-Assistant als Consumer, nicht als Host | accepted |
|
||||||
| [005](005-sqlite.md) | SQLite als Datenbank (vorerst) | accepted |
|
| [005](005-sqlite.md) | SQLite als Datenbank (vorerst) | accepted |
|
||||||
| [006](006-inspiration-algorithmus.md) | Inspiration-Algorithmus: gewichteter Mix | proposed |
|
| [006](006-inspiration-algorithmus.md) | Inspiration-Algorithmus: gewichteter Mix | proposed |
|
||||||
|
|||||||
+5
-4
@@ -20,11 +20,12 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
|
|||||||
|
|
||||||
### M0 – Skelett + Auth-Grundgerüst
|
### M0 – Skelett + Auth-Grundgerüst
|
||||||
- Symfony 7.4 LTS, PHP 8.4, FrankenPHP: `composer.json`, `config/`
|
- Symfony 7.4 LTS, PHP 8.4, FrankenPHP: `composer.json`, `config/`
|
||||||
(framework, security, doctrine/SQLite, twig, routing), `public/index.php`
|
(framework, doctrine/SQLite, twig, routing), `public/index.php`
|
||||||
- Struktur laut `architektur.md` leergelegt (`src/UI`, `src/Logic`, `src/Data`)
|
- Struktur laut `architektur.md` leergelegt (`src/UI`, `src/Logic`, `src/Data`)
|
||||||
- Keycloak-OIDC: `league/oauth2-client`, `security.yaml`,
|
- Keycloak-OIDC: manueller `OidcClient` (kein `league/oauth2-client`, kein
|
||||||
Login/Logout auf Keycloak, `REMOTE_USER`-Claim → lokale User-Zeile (Auto-Provision)
|
Security-Bundle; vgl. ADR-003), Login/Callback/Logout im UI-Layer,
|
||||||
- Docker: `Dockerfile` (php:8.4-frankenphp), `docker-compose.yaml`
|
`sub`/`email`-Claim → lokale User-Zeile (Auto-Provision)
|
||||||
|
- Docker: `Dockerfile` (php:8.4-frankenphp), `compose.yaml`
|
||||||
(Volume `data/` für SQLite, `.env`), Network `infra-traefik_traefik-net` (external),
|
(Volume `data/` für SQLite, `.env`), Network `infra-traefik_traefik-net` (external),
|
||||||
Traefik-Labels → `essen.ljh31.de` + ACME + redirect-to-https
|
Traefik-Labels → `essen.ljh31.de` + ACME + redirect-to-https
|
||||||
- `phpunit.xml`, `tests/`, htmx + SortableJS in `public/js/`
|
- `phpunit.xml`, `tests/`, htmx + SortableJS in `public/js/`
|
||||||
|
|||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||||
|
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
|
||||||
|
return new Application($kernel);
|
||||||
|
};
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: svc-essensplanung
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_ENV: prod
|
||||||
|
APP_DEBUG: '0'
|
||||||
|
DATABASE_URL: sqlite:////data/essens.db
|
||||||
|
volumes:
|
||||||
|
- essens_data:/data
|
||||||
|
- essens_var:/app/var
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'php', '-r', '$c=@fsockopen("127.0.0.1",8787); exit($c?0:1);']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- infra-traefik_traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.essens.rule=Host(`essen.ljh31.de`)"
|
||||||
|
- "traefik.http.routers.essens.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.essens.tls=true"
|
||||||
|
- "traefik.http.routers.essens.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.essens.loadbalancer.server.port=8787"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
essens_data:
|
||||||
|
essens_var:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
infra-traefik_traefik-net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"type": "project",
|
||||||
|
"license": "proprietary",
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"doctrine/doctrine-bundle": "^3.3",
|
||||||
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
|
"doctrine/orm": "^3.7",
|
||||||
|
"symfony/asset": "^7.4",
|
||||||
|
"symfony/console": "7.4.*",
|
||||||
|
"symfony/dotenv": "7.4.*",
|
||||||
|
"symfony/flex": "^2",
|
||||||
|
"symfony/framework-bundle": "7.4.*",
|
||||||
|
"symfony/http-client": "7.4.*",
|
||||||
|
"symfony/runtime": "7.4.*",
|
||||||
|
"symfony/twig-bundle": "7.4.*",
|
||||||
|
"symfony/yaml": "7.4.*"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true,
|
||||||
|
"symfony/flex": true,
|
||||||
|
"symfony/runtime": true
|
||||||
|
},
|
||||||
|
"bump-after-update": true,
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"symfony/polyfill-ctype": "*",
|
||||||
|
"symfony/polyfill-iconv": "*",
|
||||||
|
"symfony/polyfill-php72": "*",
|
||||||
|
"symfony/polyfill-php73": "*",
|
||||||
|
"symfony/polyfill-php74": "*",
|
||||||
|
"symfony/polyfill-php80": "*",
|
||||||
|
"symfony/polyfill-php81": "*",
|
||||||
|
"symfony/polyfill-php82": "*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "phpunit",
|
||||||
|
"auto-scripts": {
|
||||||
|
"cache:clear": "symfony-cmd",
|
||||||
|
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||||
|
},
|
||||||
|
"post-install-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/symfony": "*"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"symfony": {
|
||||||
|
"allow-contrib": false,
|
||||||
|
"require": "7.4.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^13.3",
|
||||||
|
"symfony/browser-kit": "7.4.*",
|
||||||
|
"symfony/css-selector": "7.4.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6840
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||||
|
#prefix_seed: your_vendor_name/app_name
|
||||||
|
|
||||||
|
# The "app" cache stores to the filesystem by default.
|
||||||
|
# The data in this cache should persist between deploys.
|
||||||
|
# Other options include:
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
#app: cache.adapter.redis
|
||||||
|
#default_redis_provider: redis://localhost
|
||||||
|
|
||||||
|
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||||
|
#app: cache.adapter.apcu
|
||||||
|
|
||||||
|
# Namespaced pools use the above "app" backend by default
|
||||||
|
#pools:
|
||||||
|
#my.dedicated.cache: null
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
orm:
|
||||||
|
auto_mapping: true
|
||||||
|
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||||
|
mappings:
|
||||||
|
Data:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Data'
|
||||||
|
prefix: 'App\Data'
|
||||||
|
alias: Data
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: 'sqlite:///:memory:'
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
doctrine_migrations:
|
||||||
|
migrations_paths:
|
||||||
|
# namespace is arbitrary but should be different from App\Migrations
|
||||||
|
# as migrations classes should NOT be autoloaded
|
||||||
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
|
enable_profiler: false
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
|
framework:
|
||||||
|
secret: '%env(APP_SECRET)%'
|
||||||
|
|
||||||
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
|
session: true
|
||||||
|
|
||||||
|
http_client:
|
||||||
|
default_options:
|
||||||
|
timeout: 15
|
||||||
|
|
||||||
|
#esi: true
|
||||||
|
#fragments: true
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
test: true
|
||||||
|
session:
|
||||||
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
framework:
|
||||||
|
router:
|
||||||
|
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||||
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
|
default_uri: '%env(DEFAULT_URI)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
framework:
|
||||||
|
router:
|
||||||
|
strict_requirements: null
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
twig:
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
twig:
|
||||||
|
strict_variables: true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
foreach (glob(dirname(__DIR__).'/var/cache/prod/*.preload.php') ?: [] as $file) {
|
||||||
|
require $file;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
# yaml-language-server: $schema=https://symfony.com/schema/config/routing/routing.yaml
|
||||||
|
|
||||||
|
home:
|
||||||
|
path: /
|
||||||
|
controller: App\UI\Http\HomeController::index
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
login:
|
||||||
|
path: /login
|
||||||
|
controller: App\UI\Http\Auth\OidcAuthController::login
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
auth_callback:
|
||||||
|
path: /auth/callback
|
||||||
|
controller: App\UI\Http\Auth\OidcAuthController::callback
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
logout:
|
||||||
|
path: /logout
|
||||||
|
controller: App\UI\Http\Auth\OidcAuthController::logout
|
||||||
|
methods: [GET, POST]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
when@dev:
|
||||||
|
_errors:
|
||||||
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
|
prefix: /_error
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
app.debug: '%kernel.debug%'
|
||||||
|
app.expected_api_token: '%env(API_TOKEN)%'
|
||||||
|
|
||||||
|
services:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
|
exclude:
|
||||||
|
- '../src/Kernel.php'
|
||||||
|
|
||||||
|
# Router-Controller automatisch als (getaggen) Service bekannt machen
|
||||||
|
App\UI\Http\:
|
||||||
|
resource: '../src/UI/Http/'
|
||||||
|
tags:
|
||||||
|
- { name: controller.service_arguments }
|
||||||
|
|
||||||
|
App\UI\Http\Auth\OidcClient:
|
||||||
|
arguments:
|
||||||
|
$issuer: '%env(string:KEYCLOAK_ISSUER)%'
|
||||||
|
$clientId: '%env(string:KEYCLOAK_CLIENT_ID)%'
|
||||||
|
$clientSecret: '%env(default::KEYCLOAK_CLIENT_SECRET)%'
|
||||||
|
$redirectUri: '%env(string:KEYCLOAK_REDIRECT_URI)%'
|
||||||
|
|
||||||
|
App\UI\Http\ExceptionSubscriber:
|
||||||
|
arguments:
|
||||||
|
$debug: '%app.debug%'
|
||||||
|
|
||||||
|
App\UI\Http\Api\ApiTokenSubscriber:
|
||||||
|
arguments:
|
||||||
|
$expectedToken: '%app.expected_api_token%'
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260913204759 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE essens_user (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, kc_sub VARCHAR(64) NOT NULL, created_am DATETIME NOT NULL, updated_am DATETIME NOT NULL)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_email ON essens_user (email)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_kc_sub ON essens_user (kc_sub)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('DROP TABLE essens_user');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
colors="true"
|
||||||
|
failOnDeprecation="true"
|
||||||
|
failOnNotice="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1" />
|
||||||
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
|
</php>
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Project Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
|
ignoreIndirectDeprecations="true"
|
||||||
|
restrictNotices="true"
|
||||||
|
restrictWarnings="true"
|
||||||
|
>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
|
||||||
|
<deprecationTrigger>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||||
|
<function>trigger_deprecation</function>
|
||||||
|
</deprecationTrigger>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
</extensions>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return static function (array $context) {
|
||||||
|
return new \App\Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
};
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Auth\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'essens_user')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_email', columns: ['email'])]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_kc_sub', columns: ['kc_sub'])]
|
||||||
|
final class UserEntity
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
|
private string $username = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
|
private string $email = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 64)]
|
||||||
|
private string $kcSub = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $createdAm;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $updatedAm;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$now = new \DateTimeImmutable();
|
||||||
|
$this->createdAm = $now;
|
||||||
|
$this->updatedAm = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
|
||||||
|
public function getUsername(): string { return $this->username; }
|
||||||
|
|
||||||
|
public function setUsername(string $username): self
|
||||||
|
{
|
||||||
|
$this->username = $username;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): string { return $this->email; }
|
||||||
|
|
||||||
|
public function setEmail(string $email): self
|
||||||
|
{
|
||||||
|
$this->email = $email;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKcSub(): string { return $this->kcSub; }
|
||||||
|
|
||||||
|
public function setKcSub(string $sub): self
|
||||||
|
{
|
||||||
|
$this->kcSub = $sub;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAm(): \DateTimeImmutable { return $this->createdAm; }
|
||||||
|
|
||||||
|
public function getUpdatedAm(): \DateTimeImmutable { return $this->updatedAm; }
|
||||||
|
|
||||||
|
public function setUpdatedAm(\DateTimeImmutable $dt): self
|
||||||
|
{
|
||||||
|
$this->updatedAm = $dt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Logic\Common\TransactionManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge von Doctrine\EntityManager auf `TransactionManagerInterface`.
|
||||||
|
*
|
||||||
|
* Verwendet Doctrine 3 `doInTransaction()` (commit + rollback).
|
||||||
|
*/
|
||||||
|
final class EntityManagerTransactionManager implements TransactionManagerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(\Closure $work): mixed
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->em->transactional(static fn () => $work($work));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->em->clear();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Infrastructure;
|
||||||
|
|
||||||
|
use App\Logic\Common\EventDispatcherInterface;
|
||||||
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as SymfonyEventDispatcherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge zurSymfony-Event-Dispatcher (kein eigenes Queue / kein Kafka nötig).
|
||||||
|
*
|
||||||
|
* Events sollten final/ReadOnly-Klassen sein (State-Carry).
|
||||||
|
*/
|
||||||
|
final class SymfonyEventDispatcherBridge implements EventDispatcherInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SymfonyEventDispatcherInterface $dispatcher,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(object $event): void
|
||||||
|
{
|
||||||
|
$this->dispatcher->dispatch($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
|
use MicroKernelTrait;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common;
|
||||||
|
|
||||||
|
interface EventDispatcherInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Sendet ein Domain-Event an alle abonnierten Listener.
|
||||||
|
*
|
||||||
|
* Das Event-Objekt muss ein finales readonly-Klasse sein,
|
||||||
|
* die nur öffentliche readonly-Felder trägt (State-Carry).
|
||||||
|
*/
|
||||||
|
public function dispatch(object $event): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common\Exception;
|
||||||
|
|
||||||
|
final class AccessDeniedException extends DomainException
|
||||||
|
{
|
||||||
|
public function getHttpStatusCode(): int
|
||||||
|
{
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common\Exception;
|
||||||
|
|
||||||
|
final class BusinessRuleViolationException extends DomainException
|
||||||
|
{
|
||||||
|
public function getHttpStatusCode(): int
|
||||||
|
{
|
||||||
|
return 422;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common\Exception;
|
||||||
|
|
||||||
|
final class ConcurrencyException extends DomainException
|
||||||
|
{
|
||||||
|
public function getHttpStatusCode(): int
|
||||||
|
{
|
||||||
|
return 409;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basis-Exception für alle Domänen-/Logik-/Infrastruktur-Exceptions.
|
||||||
|
*
|
||||||
|
* Wird zentral in `App\UI\Http\ExceptionSubscriber` auf HTTP-Status gemappt.
|
||||||
|
*
|
||||||
|
* @author Jens
|
||||||
|
*/
|
||||||
|
abstract class DomainException extends \RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mappt die Exception auf einen HTTP-Status-Code (4xx/5xx).
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
* @see https://datatracker.ietf.org/doc/html/rfc9110
|
||||||
|
*/
|
||||||
|
abstract public function getHttpStatusCode(): int;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common\Exception;
|
||||||
|
|
||||||
|
final class InfrastructureException extends DomainException
|
||||||
|
{
|
||||||
|
public function getHttpStatusCode(): int
|
||||||
|
{
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common\Exception;
|
||||||
|
|
||||||
|
final class ResourceNotFoundException extends DomainException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $resource,
|
||||||
|
int $id = 0,
|
||||||
|
) {
|
||||||
|
parent::__construct(sprintf('Ressource "%s" (id=%s) nicht gefunden.', $resource, $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHttpStatusCode(): int
|
||||||
|
{
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common\Exception;
|
||||||
|
|
||||||
|
final class ValidationException extends DomainException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, list<string>> Feld → Liste von Fehlermeldungen
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $errors = [],
|
||||||
|
) {
|
||||||
|
parent::__construct('Validierungsfehler.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, list<string>> $errors */
|
||||||
|
public static function fieldErrors(array $errors): self
|
||||||
|
{
|
||||||
|
return new self($errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function getErrors(): array
|
||||||
|
{
|
||||||
|
return $this->errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHttpStatusCode(): int
|
||||||
|
{
|
||||||
|
return 422;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Logic\Common;
|
||||||
|
|
||||||
|
interface TransactionManagerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Führt den Closure innerhalb einer Datenbank-Transaktion aus.
|
||||||
|
* Bei Exception wird zurückgerollt, ansonsten committiert.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param callable(\Closure|\Throwable): T $work
|
||||||
|
* @return T
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function execute(\Closure $work): mixed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http\Api;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `X-Api-Token`-Guard für `/api/*`.
|
||||||
|
*
|
||||||
|
* Erwartet einen statischen Token aus der Umgebungsvariablen `API_TOKEN`
|
||||||
|
* (siehe .env.dist). Bei Fehlerschlag → 401.
|
||||||
|
*
|
||||||
|
* Nur für stateless Consumer (z. B. Home Assistant). Für die Web-Session
|
||||||
|
* ist die SessionAuthSubscriber zuständig.
|
||||||
|
*/
|
||||||
|
final class ApiTokenSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $expectedToken,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RequestEvent::class => ['handle', 40],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
if (true !== $event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $event->getRequest();
|
||||||
|
if (!str_starts_with($request->getPathInfo(), '/api/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provided = (string) $request->headers->get('X-Api-Token', '');
|
||||||
|
|
||||||
|
if ($this->expectedToken === null
|
||||||
|
|| $this->expectedToken === ''
|
||||||
|
|| !hash_equals($this->expectedToken, $provided)) {
|
||||||
|
throw new UnauthorizedHttpException('api', 'API-Token fehlt oder ist ungültig.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http\Auth;
|
||||||
|
|
||||||
|
use App\Data\Auth\Entity\UserEntity;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keycloak-OIDC-Flow (Authorization-Code):
|
||||||
|
*
|
||||||
|
* /login → /auth/callback (State + CSRF)
|
||||||
|
* /auth/callback → Session-Set → /
|
||||||
|
* /logout → Session-Löschen → /login
|
||||||
|
*
|
||||||
|
* Session-Keys: `auth_sub`, `auth_name`, `auth_email`
|
||||||
|
* (für den `SessionAuthSubscriber`, der für protected routes prüft.)
|
||||||
|
*/
|
||||||
|
final class OidcAuthController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OidcClient $oidc,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly UrlGeneratorInterface $router,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private function session(Request $request): SessionInterface
|
||||||
|
{
|
||||||
|
return $request->getSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /login → Keycloak-Auth-Endpoint */
|
||||||
|
public function login(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$session = $this->session($request);
|
||||||
|
$session->set('oidc_state', bin2hex(random_bytes(24)));
|
||||||
|
|
||||||
|
return new RedirectResponse(
|
||||||
|
$this->oidc->authorizationEndpoint($session->get('oidc_state'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /auth/callback?code=…&state=… — OIDC-Finalisierung + Auto-Provision */
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$session = $this->session($request);
|
||||||
|
|
||||||
|
$state = (string) $request->query->get('state', '');
|
||||||
|
$code = (string) $request->query->get('code', '');
|
||||||
|
$error = (string) $request->query->get('error', '');
|
||||||
|
$sessionState = (string) $session->get('oidc_state', '');
|
||||||
|
|
||||||
|
if ($error !== '' || $state !== $sessionState || $state === '' || $code === '') {
|
||||||
|
$session->remove('oidc_state');
|
||||||
|
return new RedirectResponse($this->router->generate('login'));
|
||||||
|
}
|
||||||
|
$session->remove('oidc_state');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tokens = $this->oidc->exchangeCode($code);
|
||||||
|
$claims = $this->oidc->verifyIdToken($tokens['id_token']);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return new RedirectResponse($this->router->generate('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = (string) ($claims['sub'] ?? '');
|
||||||
|
$username = (string) ($claims['preferred_username'] ?? '');
|
||||||
|
$email = (string) ($claims['email'] ?? '');
|
||||||
|
|
||||||
|
if ($sub === '' || $email === '') {
|
||||||
|
return new RedirectResponse($this->router->generate('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Zeile persistieren (Auto-Provision, für spätere Features)
|
||||||
|
$user = $this->em
|
||||||
|
->getRepository(UserEntity::class)
|
||||||
|
->findOneBy(['kcSub' => $sub]);
|
||||||
|
if ($user === null) {
|
||||||
|
$user = new UserEntity();
|
||||||
|
$user->setKcSub($sub);
|
||||||
|
$this->em->persist($user);
|
||||||
|
}
|
||||||
|
$user->setUsername($username === '' ? $email : $username);
|
||||||
|
$user->setEmail($email);
|
||||||
|
$user->setUpdatedAm(new \DateTimeImmutable());
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$session->set('auth_sub', $sub);
|
||||||
|
$session->set('auth_name', $username === '' ? $email : $username);
|
||||||
|
$session->set('auth_email', $email);
|
||||||
|
|
||||||
|
return new RedirectResponse($this->router->generate('home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /logout → Session-Löschen → /login */
|
||||||
|
public function logout(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->session($request)->invalidate();
|
||||||
|
return new RedirectResponse($this->router->generate('login'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http\Auth;
|
||||||
|
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direkter OIDC-Client für Keycloak (realm-agnostisch – issuer reicht).
|
||||||
|
*
|
||||||
|
* Flow: authorization_code (Confidential-Client mit client_secret, PKCE optional aus).
|
||||||
|
*/
|
||||||
|
final class OidcClient
|
||||||
|
{
|
||||||
|
/** @var string[] */
|
||||||
|
private array $scopes;
|
||||||
|
private string $issuer;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
string $issuer,
|
||||||
|
private readonly string $clientId,
|
||||||
|
private readonly ?string $clientSecret,
|
||||||
|
private readonly string $redirectUri,
|
||||||
|
/** @var string[] */
|
||||||
|
array $scopes = ['openid', 'email', 'profile'],
|
||||||
|
) {
|
||||||
|
$this->issuer = rtrim($issuer, '/');
|
||||||
|
$this->scopes = $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issuer(): string { return $this->issuer; }
|
||||||
|
public function redirectUri(): string { return $this->redirectUri; }
|
||||||
|
public function clientId(): string { return $this->clientId; }
|
||||||
|
|
||||||
|
public function authorizationEndpoint(?string $state = null): string
|
||||||
|
{
|
||||||
|
$q = [
|
||||||
|
'response_type' => 'code',
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'scope' => implode(' ', $this->scopes),
|
||||||
|
'prompt' => 'login',
|
||||||
|
];
|
||||||
|
if ($state !== null && $state !== '') {
|
||||||
|
$q['state'] = $state;
|
||||||
|
}
|
||||||
|
return $this->issuer . '/protocol/openid-connect/auth?' . http_build_query($q);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $code Authorization-Code aus Keycloak-Redirect
|
||||||
|
* @return array{
|
||||||
|
* id_token: string,
|
||||||
|
* access_token: string,
|
||||||
|
* token_type: string
|
||||||
|
* }
|
||||||
|
* @throws \RuntimeException
|
||||||
|
*/
|
||||||
|
public function exchangeCode(string $code): array
|
||||||
|
{
|
||||||
|
$body = [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'code' => $code,
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
];
|
||||||
|
if ($this->clientSecret !== null && $this->clientSecret !== '') {
|
||||||
|
$body['client_secret'] = $this->clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [
|
||||||
|
'json' => $body,
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
$sc = $resp->getStatusCode();
|
||||||
|
$content = $resp->getContent(false);
|
||||||
|
if ($sc >= 400) {
|
||||||
|
throw new \RuntimeException("Keycloak token-Request fehlgeschlagen (HTTP $sc): $content");
|
||||||
|
}
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
if (!is_array($data) || empty($data['id_token'])) {
|
||||||
|
throw new \RuntimeException('Keycloak token-Response fehlte id_token.');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id_token' => $data['id_token'],
|
||||||
|
'access_token' => $data['access_token'] ?? '',
|
||||||
|
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifiziert ein IdToken (RS256) aus Keycloak:
|
||||||
|
* - issuer === configured
|
||||||
|
* - audience enthält clientId (oder fehlt)
|
||||||
|
* - exp & nbf
|
||||||
|
* - Signatur gegen Keycloak JWKS
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Claims
|
||||||
|
* @throws \RuntimeException
|
||||||
|
*/
|
||||||
|
public function verifyIdToken(string $idToken): array
|
||||||
|
{
|
||||||
|
$parts = explode('.', trim($idToken));
|
||||||
|
if (count($parts) !== 3) {
|
||||||
|
throw new \RuntimeException('Ungültiges IdToken-Format.');
|
||||||
|
}
|
||||||
|
[$h64, $p64, $s64] = $parts;
|
||||||
|
$header = $this->jwtB64ToJson($h64, 'header');
|
||||||
|
$payload = $this->jwtB64ToJson($p64, 'payload');
|
||||||
|
|
||||||
|
if (($header['alg'] ?? null) !== 'RS256') {
|
||||||
|
throw new \RuntimeException('Nur RS256-JWTs werden verifiziert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($payload['iss'] ?? null) !== $this->issuer) {
|
||||||
|
throw new \RuntimeException('IdToken-iss passt nicht zu issuer.');
|
||||||
|
}
|
||||||
|
$aud = $payload['aud'] ?? null;
|
||||||
|
if (is_string($aud) && $aud !== $this->clientId && !in_array($this->clientId, [$aud], true)) {
|
||||||
|
throw new \RuntimeException('IdToken aud enthält nicht clientId.');
|
||||||
|
}
|
||||||
|
if (is_array($aud) && !in_array($this->clientId, $aud, true)) {
|
||||||
|
throw new \RuntimeException('IdToken aud enthält nicht clientId.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
if (isset($payload['exp']) && (int) $payload['exp'] < $now) {
|
||||||
|
throw new \RuntimeException('IdToken abgelaufen.');
|
||||||
|
}
|
||||||
|
if (isset($payload['nbf']) && (int) $payload['nbf'] > $now + 60) {
|
||||||
|
throw new \RuntimeException('IdToken nbf in der Zukunft.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Signatur ---
|
||||||
|
$jwk = $this->findJwkForKid($header['kid'] ?? null);
|
||||||
|
$pem = $this->buildRs256PemFromJwk($jwk);
|
||||||
|
|
||||||
|
if (openssl_verify("$h64.$p64", $this->b64urlDecode($s64), $pem, OPENSSL_ALGO_SHA256) !== 1) {
|
||||||
|
throw new \RuntimeException('IdToken-Signatur ungültig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jwtB64ToJson(string $b64url, string $part): array
|
||||||
|
{
|
||||||
|
$json = json_decode($this->b64urlDecode($b64url), true);
|
||||||
|
if (!is_array($json)) {
|
||||||
|
throw new \RuntimeException("IdToken-$part war kein JSON.");
|
||||||
|
}
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function b64urlDecode(string $b64url): string
|
||||||
|
{
|
||||||
|
$b64url = strtr($b64url, '-_', '+/');
|
||||||
|
switch (strlen($b64url) % 4) {
|
||||||
|
case 2: $b64url .= '=='; break;
|
||||||
|
case 3: $b64url .= '='; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
$raw = base64_decode($b64url, true);
|
||||||
|
if ($raw === false) {
|
||||||
|
throw new \RuntimeException('IdToken b64url decodefehler.');
|
||||||
|
}
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function findJwkForKid(?string $kid): ?array
|
||||||
|
{
|
||||||
|
$resp = $this->httpClient->request('GET', $this->issuer . '/protocol/openid-connect/certs', ['timeout' => 10]);
|
||||||
|
$data = $resp->toArray(false);
|
||||||
|
$keys = $data['keys'] ?? null;
|
||||||
|
if (!is_array($keys) || $keys === []) {
|
||||||
|
throw new \RuntimeException('Keycloak JWKS leer.');
|
||||||
|
}
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!is_array($key)) continue;
|
||||||
|
if ($kid !== null && ($key['kid'] ?? null) !== $kid) continue;
|
||||||
|
if (($key['alg'] ?? null) === 'RS256') {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new \RuntimeException('Kein passender JWKS-RS256-Key gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $jwk */
|
||||||
|
private function buildRs256PemFromJwk(array $jwk): string
|
||||||
|
{
|
||||||
|
if (($jwk['kty'] ?? null) !== 'RSA') {
|
||||||
|
throw new \RuntimeException('JWKS-Key ist kein RSA.');
|
||||||
|
}
|
||||||
|
$n = $this->b64urlDecode($jwk['n']);
|
||||||
|
$e = $this->b64urlDecode($jwk['e']);
|
||||||
|
|
||||||
|
$mod = $this->derInteger($n);
|
||||||
|
$exp = $this->derInteger($e);
|
||||||
|
$rsakey =
|
||||||
|
"\x30" . $this->derLen(strlen($mod) + strlen($exp))
|
||||||
|
. "\x02" . $this->derLen(strlen($mod)) . $mod
|
||||||
|
. "\x02" . $this->derLen(strlen($exp)) . $exp;
|
||||||
|
|
||||||
|
// rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01
|
||||||
|
$algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
|
||||||
|
$algSeq = "\x30\x0d" . $algId . "\x05\x00";
|
||||||
|
$bitstring = "\x03" . $this->derLen(strlen($rsakey) + 1) . "\x00" . $rsakey;
|
||||||
|
$spki = "\x30" . $this->derLen(strlen($algSeq) + strlen($bitstring)) . $algSeq . $bitstring;
|
||||||
|
|
||||||
|
$pem = "-----BEGIN PUBLIC KEY-----\n"
|
||||||
|
. chunk_split(base64_encode($spki), 64, "\n")
|
||||||
|
. "-----END PUBLIC KEY-----\n";
|
||||||
|
return $pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function derInteger(string $data): string
|
||||||
|
{
|
||||||
|
if ($data === '') throw new \RuntimeException('Mod/Exp leer.');
|
||||||
|
if (ord($data[0]) & 0x80) {
|
||||||
|
$data = "\x00" . $data;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function derLen(int $len): string
|
||||||
|
{
|
||||||
|
if ($len < 128) {
|
||||||
|
return chr($len);
|
||||||
|
}
|
||||||
|
$bytes = '';
|
||||||
|
while ($len > 0) {
|
||||||
|
$bytes = chr($len & 0xFF) . $bytes;
|
||||||
|
$len >>= 8;
|
||||||
|
}
|
||||||
|
return chr(0x80 | strlen($bytes)) . $bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http\Auth;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-Auth-Gate für die Front-App.
|
||||||
|
*
|
||||||
|
* Protected (default): `auth_sub` in der Session muss vorhanden sein,
|
||||||
|
* sonst Redirect auf /login (bzw. 401-JSON für API-Requests – die
|
||||||
|
* behandelt die ApiTokenSubscriber mit höherer Priorität).
|
||||||
|
*
|
||||||
|
* Public-Pfade werden übersprungen.
|
||||||
|
*/
|
||||||
|
final class SessionAuthSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
private const PUBLIC_PREFIXES = [
|
||||||
|
'/login',
|
||||||
|
'/auth/',
|
||||||
|
'/logout',
|
||||||
|
'/api/',
|
||||||
|
'/css/',
|
||||||
|
'/js/',
|
||||||
|
'/images/',
|
||||||
|
'/fonts/',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UrlGeneratorInterface $router,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RequestEvent::class => ['handle', 30],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
if (true !== $event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $event->getRequest();
|
||||||
|
foreach (self::PUBLIC_PREFIXES as $prefix) {
|
||||||
|
if (str_starts_with($request->getPathInfo(), $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('' === (string) $request->getSession()->get('auth_sub', '')) {
|
||||||
|
$event->setResponse(
|
||||||
|
new RedirectResponse(
|
||||||
|
$this->router->generate('login', ['redirect' => $request->getPathInfo()])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http;
|
||||||
|
|
||||||
|
use App\Logic\Common\Exception\DomainException;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentraler Exception-Subscriber.
|
||||||
|
*
|
||||||
|
* - DomainException → HTTP-Status gemäß getHttpStatusCode().
|
||||||
|
* - HttpException → bei /api/* als JSON.
|
||||||
|
* - Sonst → 500 (Details nur in dev).
|
||||||
|
*/
|
||||||
|
final class ExceptionSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly bool $debug = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ExceptionEvent::class => ['handle', 200],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ExceptionEvent $event): void
|
||||||
|
{
|
||||||
|
$e = $event->getThrowable();
|
||||||
|
$request = $event->getRequest();
|
||||||
|
$api = str_starts_with($request->getPathInfo(), '/api/');
|
||||||
|
$wantsJson = $api || $request->headers->get('Accept') === 'application/json';
|
||||||
|
|
||||||
|
if ($e instanceof DomainException) {
|
||||||
|
$event->setResponse(
|
||||||
|
$this->render($request, $e->getHttpStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($e instanceof HttpExceptionInterface && $api) {
|
||||||
|
$event->setResponse(
|
||||||
|
$this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $this->debug ? sprintf('%s: %s', get_debug_type($e), $e->getMessage()) : 'Interner Serverfehler.';
|
||||||
|
$event->setResponse($this->render($request, 500, 'Internal', $message, $wantsJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shortName(string $class): string
|
||||||
|
{
|
||||||
|
$pos = strrpos($class, '\\');
|
||||||
|
$name = $pos === false ? $class : substr($class, (int) $pos + 1);
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render(Request $request, int $status, string $name, string $message, bool $wantsJson): Response
|
||||||
|
{
|
||||||
|
$body = ['error' => $name, 'message' => $message];
|
||||||
|
|
||||||
|
return str_starts_with($request->getPathInfo(), '/api/') || $wantsJson
|
||||||
|
? new JsonResponse($body, $status)
|
||||||
|
: new Response($message, $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class HomeController extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET / — Hello-World-Statusseite (M0).
|
||||||
|
*/
|
||||||
|
public function index(Request $request, string $redirect = '/'): Response
|
||||||
|
{
|
||||||
|
$session = $request->getSession();
|
||||||
|
$name = (string) $session->get('auth_name', '');
|
||||||
|
$email = (string) $session->get('auth_email', '');
|
||||||
|
|
||||||
|
return $this->render('home/index.html.twig', [
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'redirect' => $redirect === '' ? '/' : $redirect,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"doctrine/deprecations": {
|
||||||
|
"version": "1.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-bundle": {
|
||||||
|
"version": "3.3",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.0",
|
||||||
|
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine.yaml",
|
||||||
|
"src/Entity/.gitignore",
|
||||||
|
"src/Repository/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-migrations-bundle": {
|
||||||
|
"version": "4.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.1",
|
||||||
|
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine_migrations.yaml",
|
||||||
|
"migrations/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phpunit/phpunit": {
|
||||||
|
"version": "13.3",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "11.1",
|
||||||
|
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env.test",
|
||||||
|
"phpunit.dist.xml",
|
||||||
|
"tests/bootstrap.php",
|
||||||
|
"bin/phpunit"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/console": {
|
||||||
|
"version": "7.4",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "5.3",
|
||||||
|
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/console"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/flex": {
|
||||||
|
"version": "2.11",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "2.4",
|
||||||
|
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env",
|
||||||
|
".env.dev"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/framework-bundle": {
|
||||||
|
"version": "7.4",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.4",
|
||||||
|
"ref": "5ea2ed9cd29ca8fc2cb9bca9cb7b7e0a8d2be1dc"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/cache.yaml",
|
||||||
|
"config/packages/framework.yaml",
|
||||||
|
"config/preload.php",
|
||||||
|
"config/routes/framework.yaml",
|
||||||
|
"config/services.yaml",
|
||||||
|
"public/index.php",
|
||||||
|
"src/Controller/.gitignore",
|
||||||
|
"src/Kernel.php",
|
||||||
|
".editorconfig"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/routing": {
|
||||||
|
"version": "7.4",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.4",
|
||||||
|
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/routing.yaml",
|
||||||
|
"config/routes.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/twig-bundle": {
|
||||||
|
"version": "7.4",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.4",
|
||||||
|
"ref": "06dacf16872358cb1f2494e089070ff2d045233a"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/twig.yaml",
|
||||||
|
"templates/base.html.twig"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Start · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h1>{{ name }}</h1>
|
||||||
|
<div class="card">
|
||||||
|
<p>Willkommen zur Essensplanung! (M0-Skelett)</p>
|
||||||
|
<p><strong>E-Mail:</strong> {{ email }}</p>
|
||||||
|
<p><strong>Redirect-Pfad:</strong> <code>{{ redirect }}</code></p>
|
||||||
|
<p>M1: CRUD-Slice (Dish-Create/Update/Delete/List) folgt.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" dir="ltr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}svc-essensplanung{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f4f4;
|
||||||
|
--fg: #112233;
|
||||||
|
--card: #fff;
|
||||||
|
--accent: #0a7bff;
|
||||||
|
--muted: #667;
|
||||||
|
--warn: #b45309;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: 0 1px 0 #ddd;
|
||||||
|
}
|
||||||
|
header a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block stylesheets %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<strong>essen.ljh31.de</strong>
|
||||||
|
<nav>
|
||||||
|
<a href="logout">Abmelden</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script src="{{ asset('js/htmx.min.js') }}"></script>
|
||||||
|
{% block javascripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\BrowserKit\Cookie;
|
||||||
|
|
||||||
|
final class SmokeTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const AUTH_KEYS = [
|
||||||
|
'auth_sub' => 'test-sub',
|
||||||
|
'auth_name' => 'Jens',
|
||||||
|
'auth_email' => 'jens@ljh31.de',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static function client(): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->followRedirects(false);
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client, dessen Session bereits eine eingeloggte Identität enthält. */
|
||||||
|
private static function authedClient(): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = self::client();
|
||||||
|
$session = self::getContainer()->get('session.factory')->createSession();
|
||||||
|
$session->start();
|
||||||
|
foreach (self::AUTH_KEYS as $k => $v) {
|
||||||
|
$session->set($k, $v);
|
||||||
|
}
|
||||||
|
$session->save();
|
||||||
|
$client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRootRedirectsToLoginWithoutSession(): void
|
||||||
|
{
|
||||||
|
self::client()->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(302);
|
||||||
|
self::assertResponseRedirects('/login?redirect=/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginRedirectsToKeycloakAuthorizeEndpoint(): void
|
||||||
|
{
|
||||||
|
$client = self::client();
|
||||||
|
$client->request('GET', '/login');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(302);
|
||||||
|
$location = (string) $client->getResponse()->headers->get('Location');
|
||||||
|
self::assertStringContainsString('/protocol/openid-connect/auth', $location);
|
||||||
|
self::assertStringContainsString('client_id=essens-test', $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCallbackWithWrongStateRedirectsBackToLogin(): void
|
||||||
|
{
|
||||||
|
self::client()->request('GET', '/auth/callback?code=xyz&state=falsch-state');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(302);
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApiWithoutTokenReturns401Json(): void
|
||||||
|
{
|
||||||
|
$client = self::client();
|
||||||
|
$client->request('GET', '/api/today');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
$body = json_decode((string) $client->getResponse()->getContent(), true);
|
||||||
|
self::assertIsArray($body);
|
||||||
|
self::assertArrayHasKey('error', $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApiWithValidTokenGets404Not401(): void
|
||||||
|
{
|
||||||
|
// /api/today existiert in M0 noch nicht (M4) → Guard lässt durch, Router gibt 404.
|
||||||
|
$client = self::client();
|
||||||
|
$client->request('GET', '/api/today', server: ['HTTP_X_API_TOKEN' => 'test-api-token']);
|
||||||
|
|
||||||
|
self::assertSame(404, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRootRendersForAuthenticatedSession(): void
|
||||||
|
{
|
||||||
|
self::authedClient()->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('h1', 'Jens');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutRedirectsToLogin(): void
|
||||||
|
{
|
||||||
|
self::authedClient()->request('GET', '/logout');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(302);
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
|
||||||
Reference in New Issue
Block a user