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,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user