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'); + } +}