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
+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,
]);
}
}