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