feat(security): Sessions, Login, CSRF, Rate-Limiting und Setup-Token

Session-Login mit genau einem Admin-Konto, Passwort als bcrypt-Hash.
Sessions mit HttpOnly, SameSite=Lax, strict mode, ID-Wechsel beim Login,
Inaktivitaets- und absoluter Zeitgrenze; ueber HTTPS wird das Cookie
automatisch auf secure gesetzt. Ein Passwortwechsel entwertet
bestehende Sitzungen.

Das Rate-Limiting zaehlt Fehlversuche pro IP-Hash - die IP selbst wird
nie gespeichert.

SetupToken schliesst das Zeitfenster zwischen Upload und erstem Aufruf:
ohne das Einmal-Token aus dem Datenverzeichnis laesst sich kein Konto
anlegen.
This commit is contained in:
2026-08-03 18:11:39 +02:00
parent 3ca6a03149
commit e3d8e70a38
5 changed files with 557 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Config;
use App\Repository\SettingRepository;
/**
* Session-basierte Authentifizierung mit genau einem Admin-Konto.
*
* Die Zugangsdaten kommen entweder aus der .env (ADMIN_USER /
* ADMIN_PASSWORD_HASH) oder - falls dort nichts gesetzt ist - aus der
* settings-Tabelle, die der Setup-Assistent befuellt.
*/
final class Auth
{
private const SESSION_KEY = '_admin_authenticated';
private const SESSION_USER = '_admin_user';
private const SESSION_FINGERPRINT = '_admin_fingerprint';
public function __construct(
private readonly Config $config,
private readonly Session $session,
private readonly SettingRepository $settings,
) {
}
public function username(): string
{
$fromEnv = $this->config->getString('ADMIN_USER', 'admin');
// Steht das Passwort in der .env, gilt auch der Benutzername von dort.
if ($this->config->adminPasswordHash() !== null) {
return $fromEnv;
}
return $this->settings->get(SettingRepository::ADMIN_USER, $fromEnv) ?? $fromEnv;
}
public function passwordHash(): ?string
{
return $this->config->adminPasswordHash()
?? $this->settings->get(SettingRepository::ADMIN_PASSWORD_HASH);
}
/**
* Ist bereits ein Admin-Konto eingerichtet?
*/
public function isConfigured(): bool
{
$hash = $this->passwordHash();
return is_string($hash) && $hash !== '';
}
/**
* Legt Zugangsdaten in der Datenbank an (Setup-Assistent).
*/
public function configure(string $username, string $plainPassword): void
{
$this->settings->set(SettingRepository::ADMIN_USER, $username);
$this->settings->set(
SettingRepository::ADMIN_PASSWORD_HASH,
password_hash($plainPassword, PASSWORD_DEFAULT),
);
}
/**
* Prueft Benutzername und Passwort in konstanter Zeit.
*/
public function verifyCredentials(string $username, string $password): bool
{
$hash = $this->passwordHash();
if (!is_string($hash) || $hash === '') {
return false;
}
// Immer beide Pruefungen ausfuehren, damit ein falscher
// Benutzername nicht schneller beantwortet wird als ein
// falsches Passwort.
$userOk = hash_equals($this->username(), $username);
$passwordOk = password_verify($password, $hash);
return $userOk && $passwordOk;
}
public function login(string $username): void
{
$this->session->regenerate();
$this->session->set(self::SESSION_KEY, true);
$this->session->set(self::SESSION_USER, $username);
$this->session->set(self::SESSION_FINGERPRINT, $this->fingerprint());
}
public function logout(): void
{
$this->session->destroy();
}
public function check(): bool
{
if ($this->session->get(self::SESSION_KEY) !== true) {
return false;
}
// Passwortwechsel invalidiert bestehende Sessions.
if ($this->session->get(self::SESSION_FINGERPRINT) !== $this->fingerprint()) {
$this->session->destroy();
return false;
}
return true;
}
public function currentUser(): ?string
{
$user = $this->session->get(self::SESSION_USER);
return is_string($user) ? $user : null;
}
private function fingerprint(): string
{
return hash('sha256', (string) $this->passwordHash());
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Security;
/**
* CSRF-Schutz per Synchronizer-Token (ein Token pro Session).
*/
final class Csrf
{
public const FIELD = '_csrf';
public function __construct(private readonly Session $session)
{
}
public function token(): string
{
$token = $this->session->get('_csrf_token');
if (!is_string($token) || strlen($token) !== 64) {
$token = bin2hex(random_bytes(32));
$this->session->set('_csrf_token', $token);
}
return $token;
}
public function isValid(?string $candidate): bool
{
if (!is_string($candidate) || $candidate === '') {
return false;
}
$token = $this->session->get('_csrf_token');
return is_string($token) && hash_equals($token, $candidate);
}
/**
* Verstecktes Formularfeld.
*/
public function field(): string
{
return sprintf(
'<input type="hidden" name="%s" value="%s">',
self::FIELD,
htmlspecialchars($this->token(), ENT_QUOTES, 'UTF-8'),
);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Database;
/**
* Rate-Limiting fuer den Login. Speichert nur einen gesalzenen Hash
* der IP-Adresse, niemals die IP selbst.
*/
final class RateLimiter
{
public function __construct(
private readonly Database $db,
private readonly int $maxAttempts = 5,
private readonly int $lockoutSeconds = 900,
) {
}
private function identifier(string $ip): string
{
return substr(hash('sha256', 'login|' . $ip), 0, 64);
}
/**
* Anzahl der Fehlversuche im aktuellen Zeitfenster.
*/
public function failedAttempts(string $ip): int
{
$since = gmdate('Y-m-d H:i:s', time() - $this->lockoutSeconds);
$count = $this->db->fetchValue(
'SELECT COUNT(*) FROM login_attempts WHERE identifier = ? AND successful = 0 AND attempted_at > ?',
[$this->identifier($ip), $since],
);
return (int) $count;
}
public function isLocked(string $ip): bool
{
return $this->failedAttempts($ip) >= $this->maxAttempts;
}
public function remainingAttempts(string $ip): int
{
return max(0, $this->maxAttempts - $this->failedAttempts($ip));
}
/**
* Verbleibende Sperrzeit in Sekunden.
*/
public function retryAfter(string $ip): int
{
$row = $this->db->fetchOne(
'SELECT attempted_at FROM login_attempts
WHERE identifier = ? AND successful = 0
ORDER BY attempted_at DESC LIMIT 1',
[$this->identifier($ip)],
);
if ($row === null) {
return 0;
}
$last = strtotime((string) $row['attempted_at'] . ' UTC') ?: time();
return max(0, $this->lockoutSeconds - (time() - $last));
}
public function recordFailure(string $ip): void
{
$this->db->execute(
'INSERT INTO login_attempts (identifier, attempted_at, successful) VALUES (?, ?, 0)',
[$this->identifier($ip), gmdate('Y-m-d H:i:s')],
);
$this->pruneOldEntries();
}
public function recordSuccess(string $ip): void
{
// Erfolgreicher Login setzt den Zaehler zurueck.
$this->db->execute(
'DELETE FROM login_attempts WHERE identifier = ?',
[$this->identifier($ip)],
);
}
private function pruneOldEntries(): void
{
$cutoff = gmdate('Y-m-d H:i:s', time() - max($this->lockoutSeconds, 86400));
$this->db->execute('DELETE FROM login_attempts WHERE attempted_at < ?', [$cutoff]);
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Config;
/**
* Sichere Session-Konfiguration.
*/
final class Session
{
private bool $started = false;
public function __construct(private readonly Config $config)
{
}
/**
* @param bool $isSecureRequest Laeuft die Anfrage ueber HTTPS?
* Dann wird das Cookie automatisch auf "secure" gesetzt,
* auch ohne ausdrueckliche Konfiguration.
*/
public function start(bool $isSecureRequest = false): void
{
if ($this->started || PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
$this->started = true;
return;
}
$lifetime = $this->config->getInt('SESSION_LIFETIME', 7200);
$absoluteLifetime = $this->config->getInt('SESSION_ABSOLUTE_LIFETIME', 43200);
session_name($this->config->getString('SESSION_NAME', 'urlshort_session'));
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $this->config->getBool('SESSION_SECURE', false) || $isSecureRequest,
'httponly' => true,
'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.gc_maxlifetime', (string) $lifetime);
ini_set('session.sid_length', '48');
ini_set('session.sid_bits_per_character', '5');
session_start();
$this->started = true;
$now = time();
// Inaktivitaetsgrenze
$last = $this->get('_last_activity');
if (is_int($last) && $now - $last > $lifetime) {
$this->destroy();
session_start();
}
// Absolute Obergrenze, unabhaengig von der Aktivitaet: eine
// gestohlene Sitzung bleibt so nicht beliebig lange nutzbar.
$startedAt = $this->get('_started_at');
if ($absoluteLifetime > 0 && is_int($startedAt) && $now - $startedAt > $absoluteLifetime) {
$this->destroy();
session_start();
$startedAt = null;
}
if (!is_int($startedAt)) {
$this->set('_started_at', $now);
}
$this->set('_last_activity', $now);
}
public function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}
public function set(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}
public function remove(string $key): void
{
unset($_SESSION[$key]);
}
public function regenerate(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
// Frische Sitzung: die absolute Laufzeit beginnt neu.
$this->set('_started_at', time());
}
}
public function destroy(): void
{
$_SESSION = [];
if (session_status() === PHP_SESSION_ACTIVE) {
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name() ?: 'urlshort_session', '', [
'expires' => time() - 42000,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => 'Lax',
]);
}
session_destroy();
}
}
/**
* Einmal-Meldung setzen (Flash).
*/
public function flash(string $type, string $message): void
{
$messages = $this->get('_flash', []);
$messages = is_array($messages) ? $messages : [];
$messages[] = ['type' => $type, 'message' => $message];
$this->set('_flash', $messages);
}
/**
* @return list<array{type:string,message:string}>
*/
public function takeFlashes(): array
{
$messages = $this->get('_flash', []);
$this->remove('_flash');
/** @var list<array{type:string,message:string}> */
return is_array($messages) ? array_values($messages) : [];
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Security;
use RuntimeException;
/**
* Einmal-Token fuer die Ersteinrichtung.
*
* Ohne diesen Schutz gehoert eine frische Installation demjenigen, der
* sie als Erster aufruft - zwischen Upload und eigenem Erstaufruf steht
* der Setup-Assistent sonst jedem offen. Das Token liegt als Datei im
* Datenverzeichnis (ausserhalb des Webroots) und muss beim Einrichten
* abgetippt werden. Wer keinen Datei- oder Log-Zugriff auf den Server
* hat, kommt damit nicht an die Einrichtung.
*
* Nach abgeschlossener Einrichtung wird die Datei geloescht.
*/
final class SetupToken
{
private const FILENAME = 'setup-token.txt';
public function __construct(private readonly string $directory)
{
}
public function path(): string
{
return $this->directory . '/' . self::FILENAME;
}
/**
* Liefert das aktuelle Token und erzeugt es beim ersten Aufruf.
*/
public function ensure(): string
{
$existing = $this->read();
if ($existing !== null) {
return $existing;
}
$token = strtoupper(bin2hex(random_bytes(8)));
$contents = <<<TEXT
Einrichtungs-Token fuer die Kurzlink-Verwaltung
==============================================
{$token}
Dieses Token wird einmalig beim Anlegen des Administrator-Kontos
abgefragt. Danach wird diese Datei automatisch geloescht.
Erzeugt am: {$this->now()}
TEXT;
if (!is_dir($this->directory) && !@mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new RuntimeException(sprintf('Verzeichnis "%s" konnte nicht angelegt werden.', $this->directory));
}
if (@file_put_contents($this->path(), $contents . "\n", LOCK_EX) === false) {
throw new RuntimeException(sprintf(
'Das Einrichtungs-Token konnte nicht nach "%s" geschrieben werden. '
. 'Bitte Schreibrechte für das Datenverzeichnis prüfen.',
$this->path(),
));
}
@chmod($this->path(), 0600);
// Damit es im Docker-Betrieb direkt in den Logs auftaucht.
error_log('[urlshort] Einrichtungs-Token: ' . $token . ' (Datei: ' . $this->path() . ')');
return $token;
}
public function exists(): bool
{
return $this->read() !== null;
}
/**
* Vergleich in konstanter Zeit, Gross-/Kleinschreibung und
* Leerzeichen werden verziehen.
*/
public function matches(string $candidate): bool
{
$token = $this->read();
if ($token === null) {
return false;
}
$candidate = strtoupper(preg_replace('/[\s-]+/', '', $candidate) ?? '');
return $candidate !== '' && hash_equals($token, $candidate);
}
public function discard(): void
{
$path = $this->path();
if (is_file($path)) {
// Vor dem Loeschen ueberschreiben, damit das Token nicht in
// freigegebenen Bloecken zurueckbleibt.
@file_put_contents($path, str_repeat("\0", 64));
@unlink($path);
}
}
private function read(): ?string
{
$path = $this->path();
if (!is_file($path) || !is_readable($path)) {
return null;
}
$contents = (string) @file_get_contents($path);
if (preg_match('/\b([0-9A-F]{16})\b/', strtoupper($contents), $matches) !== 1) {
return null;
}
return $matches[1];
}
private function now(): string
{
return date('d.m.Y H:i:s');
}
}