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:
2026-09-13 22:48:24 +02:00
co-authored by Claude Opus 4.8
parent 3c0bee5826
commit cb3cbfb394
56 changed files with 9596 additions and 19 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
var
.env
.env.local
.env.local.*
vendor
tests/tmp
*.phpunit.result.cache
+17
View File
@@ -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
+13
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
+18
View File
@@ -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
View File
@@ -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
+29 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
+37
View File
@@ -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
+79
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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],
];
+19
View File
@@ -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
+19
View File
@@ -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:'
+6
View File
@@ -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
+19
View File
@@ -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
+10
View 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
+6
View File
@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true
+5
View File
@@ -0,0 +1,5 @@
<?php
foreach (glob(dirname(__DIR__).'/var/cache/prod/*.preload.php') ?: [] as $file) {
require $file;
}
+1065
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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]
+4
View File
@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error
+36
View File
@@ -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%'
View File
+33
View File
@@ -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');
}
}
+44
View File
@@ -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>
+9
View File
@@ -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']);
};
+1
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+77
View File
@@ -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);
}
}
+11
View File
@@ -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;
}
+53
View File
@@ -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.');
}
}
}
+107
View File
@@ -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'));
}
}
+239
View File
@@ -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()])
)
);
}
}
}
+76
View File
@@ -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);
}
}
+28
View File
@@ -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
View File
@@ -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"
]
}
}
+13
View File
@@ -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 %}
+63
View File
@@ -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>
+102
View File
@@ -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');
}
}
+9
View File
@@ -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');