Initial
EK-DOS-WEB bauen und ausrollen / frontend (push) Failing after 22s
EK-DOS-WEB bauen und ausrollen / backend (push) Failing after 27s
EK-DOS-WEB bauen und ausrollen / deploy (push) Skipped

This commit is contained in:
Kyle Müller
2026-09-06 13:25:44 +02:00
commit 7d82e807fc
71 changed files with 10601 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\Support\Json;
use Ekdos\Users\Permission;
use Ekdos\Users\UserRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class AuthController
{
public function __construct(
private AuthService $auth,
private SessionCookie $cookie,
private UserRepository $users,
) {}
public function login(Request $request, Response $response): Response
{
$body = $request->getParsedBody();
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
$result = $this->auth->login(
username: (string) ($body['username'] ?? $body['account'] ?? ''),
password: (string) ($body['password'] ?? ''),
clientIp: self::clientIp($request),
);
if ($result['ok'] === false) {
return Json::error($response, $result['error'], $result['status']);
}
return $this->cookie->attach(Json::write($response, ['user' => self::describe($result['session'])]), $result['session']->id);
}
/**
* Die Auth-Sonde der Oberflaeche.
*
* Bewusst ohne RequireAuth: die SPA fragt hier vor jeder Anzeige an und
* entscheidet anhand von 200 oder 401, ob sie die Anmeldemaske zeigt.
*/
public function me(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::write($response, ['user' => null], 401);
}
return Json::write($response, ['user' => self::describe($session)]);
}
/**
* Haelt die Sitzung am Leben, solange jemand am Bildschirm arbeitet.
* Die Oberflaeche ruft das alle fuenf Minuten auf.
*/
public function refresh(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::write($response, ['user' => null], 401);
}
return Json::write($response, ['user' => self::describe($session)]);
}
public function logout(Request $request, Response $response): Response
{
$this->auth->logout($this->cookie->read($request));
return $this->cookie->clear(Json::write($response, ['ok' => true]));
}
/** Eigenes Passwort aendern. Erlaubt jedem angemeldeten Benutzer. */
public function changeOwnPassword(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::error($response, 'Bitte erneut anmelden.', 401);
}
$body = $request->getParsedBody();
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
$current = (string) ($body['currentPassword'] ?? '');
$next = (string) ($body['newPassword'] ?? '');
$user = $this->users->findByUsernameWithHash($session->username);
if ($user === null || !password_verify($current, $user->passwordHash)) {
return Json::error($response, 'Das aktuelle Passwort ist nicht korrekt.', 403);
}
if (($problem = AuthService::rejectWeakPassword($next)) !== null) {
return Json::error($response, $problem, 400);
}
$this->users->update($user->id, ['password_hash' => AuthService::hash($next)]);
$this->users->audit($user->id, $user->displayName, 'password.self', $user->id, $user->username);
// Alle anderen Sitzungen dieses Benutzers verfallen; die eigene bleibt bestehen.
return Json::write($response, ['ok' => true]);
}
/** @return array<string, mixed> */
private static function describe(Session $session): array
{
return [
'id' => $session->userId,
'username' => $session->username,
'name' => $session->displayName,
'role' => $session->role->value,
'roleLabel' => $session->role->label(),
'permissions' => $session->role->permissions(),
'canManageUsers' => $session->can(Permission::UsersManage),
];
}
/**
* Hinter nginx steht die echte Adresse in X-Forwarded-For. Es wird nur der
* erste Eintrag verwendet und nur, wenn der Header ueberhaupt gesetzt ist --
* der Origin ist ausschliesslich ueber den Proxy erreichbar.
*/
private static function clientIp(Request $request): string
{
$forwarded = $request->getHeaderLine('X-Forwarded-For');
if ($forwarded !== '') {
return trim(explode(',', $forwarded)[0]);
}
$server = $request->getServerParams();
return is_string($server['REMOTE_ADDR'] ?? null) ? $server['REMOTE_ADDR'] : 'unbekannt';
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\UserRepository;
use Redis;
/**
* Anmeldung gegen die eigene Benutzertabelle.
*
* Die Bremse haengt an Benutzername und Quell-IP gemeinsam, damit weder ein
* verteilter Angriff auf ein Konto noch ein einzelner Client durchprobieren kann.
*/
final readonly class AuthService
{
public function __construct(
private UserRepository $users,
private SessionStore $sessions,
private Redis $redis,
private string $prefix,
private int $maxAttempts,
private int $window,
) {}
/**
* @return array{ok: true, session: Session}|array{ok: false, error: string, status: int}
*/
public function login(string $username, string $password, string $clientIp): array
{
$username = trim($username);
if ($username === '' || $password === '') {
return ['ok' => false, 'error' => 'Benutzername und Passwort sind erforderlich.', 'status' => 400];
}
if ($this->isThrottled($username, $clientIp)) {
return ['ok' => false, 'error' => 'Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.', 'status' => 429];
}
$user = $this->users->findByUsernameWithHash($username);
// Auch bei unbekanntem Benutzer wird gehasht, damit die Antwortzeit nichts verraet.
$hash = $user?->passwordHash ?? '$argon2id$v=19$m=65536,t=4,p=1$aaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$valid = password_verify($password, $hash);
if ($user === null || !$valid) {
$this->recordFailure($username, $clientIp);
return ['ok' => false, 'error' => 'Benutzer oder Passwort ist nicht korrekt.', 'status' => 401];
}
if (!$user->isActive) {
$this->recordFailure($username, $clientIp);
return ['ok' => false, 'error' => 'Dieses Konto ist deaktiviert.', 'status' => 403];
}
$this->clearFailures($username, $clientIp);
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
$this->users->update($user->id, ['password_hash' => self::hash($password)]);
}
$this->users->touchLogin($user->id);
return ['ok' => true, 'session' => $this->sessions->create($user)];
}
public function logout(string $sessionId): void
{
if ($sessionId !== '') {
$this->sessions->destroy($sessionId);
}
}
public static function hash(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID);
}
/** Mindestanforderung an ein Passwort. Bewusst schlicht: Laenge schlaegt Zeichenklassen. */
public static function rejectWeakPassword(string $password): ?string
{
if (mb_strlen($password) < 12) {
return 'Das Passwort muss mindestens 12 Zeichen lang sein.';
}
if (mb_strlen($password) > 200) {
return 'Das Passwort darf höchstens 200 Zeichen lang sein.';
}
return null;
}
private function isThrottled(string $username, string $clientIp): bool
{
return (int) ($this->redis->get($this->throttleKey($username, $clientIp)) ?: 0) >= $this->maxAttempts;
}
private function recordFailure(string $username, string $clientIp): void
{
$key = $this->throttleKey($username, $clientIp);
if ((int) $this->redis->incr($key) === 1) {
$this->redis->expire($key, $this->window);
}
}
private function clearFailures(string $username, string $clientIp): void
{
$this->redis->del($this->throttleKey($username, $clientIp));
}
private function throttleKey(string $username, string $clientIp): string
{
return $this->prefix . 'login:' . sha1(strtolower($username) . '|' . $clientIp);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\Role;
/**
* Der serverseitige Teil der Sitzung. Der Browser kennt davon nichts ausser
* der undurchsichtigen Kennung im Cookie.
*/
final readonly class Session
{
public function __construct(
public string $id,
public string $userId,
public string $username,
public string $displayName,
public Role $role,
public int $issuedAt,
public int $lastSeenAt,
) {}
public static function fromArray(string $id, array $data): ?self
{
$role = Role::tryFrom((string) ($data['role'] ?? ''));
if ($role === null || !isset($data['userId'], $data['username'])) {
return null;
}
return new self(
id: $id,
userId: (string) $data['userId'],
username: (string) $data['username'],
displayName: (string) ($data['displayName'] ?? $data['username']),
role: $role,
issuedAt: (int) ($data['issuedAt'] ?? 0),
lastSeenAt: (int) ($data['lastSeenAt'] ?? 0),
);
}
public function toArray(): array
{
return [
'userId' => $this->userId,
'username' => $this->username,
'displayName' => $this->displayName,
'role' => $this->role->value,
'issuedAt' => $this->issuedAt,
'lastSeenAt' => $this->lastSeenAt,
];
}
public function can(string $permission): bool
{
return $this->role->allows($permission);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Das Cookie traegt nur die Sitzungskennung.
*
* Ohne Max-Age bleibt es ein reines Sitzungscookie: schliesst jemand den Browser
* vollstaendig, ist beim naechsten Aufruf wieder eine Anmeldung noetig. Das war
* schon in der Next.js-Fassung so und bleibt bewusst erhalten.
*/
final readonly class SessionCookie
{
public function __construct(
private string $name,
private bool $secure,
) {}
public function read(Request $request): string
{
$value = $request->getCookieParams()[$this->name] ?? '';
return is_string($value) ? $value : '';
}
public function attach(Response $response, string $id): Response
{
return $response->withAddedHeader('Set-Cookie', $this->build($id, null));
}
public function clear(Response $response): Response
{
return $response->withAddedHeader('Set-Cookie', $this->build('', 0));
}
private function build(string $value, ?int $maxAge): string
{
$parts = [
$this->name . '=' . $value,
'Path=/',
'HttpOnly',
'SameSite=Strict',
];
if ($this->secure) {
$parts[] = 'Secure';
}
if ($maxAge !== null) {
$parts[] = 'Max-Age=' . $maxAge;
}
return implode('; ', $parts);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\User;
use Redis;
/**
* Serverseitiger Sitzungsspeicher (BFF-Ticket-Store) auf Redis.
*
* Der Browser bekommt ausschliesslich eine zufaellige, undurchsichtige Kennung.
* Rollen, Rechte und Anzeigename bleiben auf dem Server: das Cookie waechst
* nicht mit den Claims mit und ein Reverse Proxy hat nie zu grosse Header.
*
* Schluessel:
* <prefix>sess:<id> Sitzungsdaten, TTL = Leerlauffenster
* <prefix>sess:user:<uid> Menge aller Sitzungen eines Benutzers, fuer Zwangsabmeldung
*/
final readonly class SessionStore
{
public function __construct(
private Redis $redis,
private string $prefix,
private int $ttl,
) {}
public function create(User $user): Session
{
$now = time();
$session = new Session(
id: self::newId(),
userId: $user->id,
username: $user->username,
displayName: $user->displayName,
role: $user->role,
issuedAt: $now,
lastSeenAt: $now,
);
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($session->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->redis->sAdd($this->userKey($user->id), $session->id);
// Der Index darf den laengsten moeglichen Sitzungslauf ueberdauern, nicht laenger.
$this->redis->expire($this->userKey($user->id), $this->ttl * 24);
return $session;
}
public function read(string $id): ?Session
{
if (!self::looksLikeId($id)) {
return null;
}
$raw = $this->redis->get($this->key($id));
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? Session::fromArray($id, $decoded) : null;
}
/**
* Gleitendes Leerlauffenster: jede authentifizierte Anfrage schiebt die TTL nach vorn.
* Der Rumpf wird nur einmal pro Minute neu geschrieben, sonst reicht ein EXPIRE.
*/
public function touch(Session $session): void
{
$now = time();
if ($now - $session->lastSeenAt < 60) {
$this->redis->expire($this->key($session->id), $this->ttl);
return;
}
$refreshed = new Session(
id: $session->id,
userId: $session->userId,
username: $session->username,
displayName: $session->displayName,
role: $session->role,
issuedAt: $session->issuedAt,
lastSeenAt: $now,
);
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($refreshed->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
public function destroy(string $id): void
{
$session = $this->read($id);
if ($session !== null) {
$this->redis->sRem($this->userKey($session->userId), $id);
}
$this->redis->del($this->key($id));
}
/**
* Meldet einen Benutzer ueberall ab. Wird nach Rollenwechsel, Deaktivierung,
* Passwortwechsel und Loeschung aufgerufen, damit eine laufende Sitzung
* keine Rechte behaelt, die der Benutzer nicht mehr hat.
*/
public function destroyAllFor(string $userId): int
{
$ids = $this->redis->sMembers($this->userKey($userId));
if (!is_array($ids) || $ids === []) {
$this->redis->del($this->userKey($userId));
return 0;
}
$this->redis->del(array_map(fn (string $id): string => $this->key($id), $ids));
$this->redis->del($this->userKey($userId));
return count($ids);
}
private static function newId(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
private static function looksLikeId(string $id): bool
{
return $id !== '' && strlen($id) <= 64 && preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1;
}
private function key(string $id): string
{
return $this->prefix . 'sess:' . $id;
}
private function userKey(string $userId): string
{
return $this->prefix . 'sess:user:' . $userId;
}
}