Initial
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user