diff --git a/src/Security/Auth.php b/src/Security/Auth.php
new file mode 100644
index 0000000..f314214
--- /dev/null
+++ b/src/Security/Auth.php
@@ -0,0 +1,130 @@
+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());
+ }
+}
diff --git a/src/Security/Csrf.php b/src/Security/Csrf.php
new file mode 100644
index 0000000..15413d7
--- /dev/null
+++ b/src/Security/Csrf.php
@@ -0,0 +1,52 @@
+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(
+ '',
+ self::FIELD,
+ htmlspecialchars($this->token(), ENT_QUOTES, 'UTF-8'),
+ );
+ }
+}
diff --git a/src/Security/RateLimiter.php b/src/Security/RateLimiter.php
new file mode 100644
index 0000000..d05ce5e
--- /dev/null
+++ b/src/Security/RateLimiter.php
@@ -0,0 +1,97 @@
+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]);
+ }
+}
diff --git a/src/Security/Session.php b/src/Security/Session.php
new file mode 100644
index 0000000..f639565
--- /dev/null
+++ b/src/Security/Session.php
@@ -0,0 +1,147 @@
+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
+ */
+ public function takeFlashes(): array
+ {
+ $messages = $this->get('_flash', []);
+ $this->remove('_flash');
+
+ /** @var list */
+ return is_array($messages) ? array_values($messages) : [];
+ }
+}
diff --git a/src/Security/SetupToken.php b/src/Security/SetupToken.php
new file mode 100644
index 0000000..07b5a6b
--- /dev/null
+++ b/src/Security/SetupToken.php
@@ -0,0 +1,131 @@
+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 = <<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');
+ }
+}