diff --git a/app/platform-admin.php b/app/platform-admin.php index ca103bc..f111600 100644 --- a/app/platform-admin.php +++ b/app/platform-admin.php @@ -5,6 +5,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/saas-auth.php'; require_once __DIR__ . '/audit.php'; +require_once __DIR__ . '/totp.php'; /** * Platform-admin access is deliberately a separate concept from @@ -44,6 +45,15 @@ function app_require_platform_admin(PDO $pdo): array exit('Kein Zugriff.'); } + // Das Back-Office sieht alle Mandanten - fuer diese Konten laesst sich ein + // zweiter Faktor erzwingen. Bewusst als Weiterleitung auf die Einrichtung + // statt als harte Sperre: sonst koennte der Schalter den einzigen + // Platform-Admin dauerhaft aussperren. + if (app_totp_required_for_platform_admins() && !app_totp_is_active($pdo, (int)$user['user_id'])) { + header('Location: zwei-faktor-einrichten.php'); + exit; + } + return $user; } diff --git a/app/prefixed-pdo.php b/app/prefixed-pdo.php index f91b26c..0139074 100644 --- a/app/prefixed-pdo.php +++ b/app/prefixed-pdo.php @@ -26,6 +26,7 @@ function app_database_table_names(): array 'payment_import_batches', 'payment_import_rows', 'stripe_webhook_events', + 'totp_recovery_codes', 'rate_limit_attempts', 'tenant_memberships', 'legal_acceptances', diff --git a/app/saas-auth.php b/app/saas-auth.php index 3cb4659..628046f 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -885,6 +885,30 @@ function saas_change_password( return ['ok' => true, 'errors' => []]; } +/** + * Bestaetigt das Passwort eines bereits angemeldeten Kontos, ohne es zu + * aendern. Gedacht fuer Aktionen, die eine bestehende Sitzung allein nicht + * rechtfertigen soll - etwa das Abschalten des zweiten Faktors. + */ +function saas_password_matches(PDO $pdo, int $userId, string $password): bool +{ + if ($password === '') { + return false; + } + + $stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if ($user === false || (string)$user['status'] !== 'active') { + return false; + } + + $hash = (string)($user['password_hash'] ?? ''); + + return $hash !== '' && password_verify($password, $hash); +} + /** * Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen * geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen - diff --git a/app/totp.php b/app/totp.php new file mode 100644 index 0000000..c59eb28 --- /dev/null +++ b/app/totp.php @@ -0,0 +1,457 @@ += 5) { + $bitsLeft -= 5; + $out .= $alphabet[($buffer >> $bitsLeft) & 31]; + } + } + if ($bitsLeft > 0) { + $out .= $alphabet[($buffer << (5 - $bitsLeft)) & 31]; + } + + return $out; +} + +function app_totp_base32_decode(string $base32): string +{ + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + // Leerzeichen und Bindestriche kommen vor, wenn jemand das Geheimnis von + // Hand aus der Anzeige abtippt; Padding ist optional. + $base32 = strtoupper(preg_replace('/[\s-]+/', '', $base32) ?? ''); + $base32 = rtrim($base32, '='); + + $buffer = 0; + $bitsLeft = 0; + $out = ''; + + for ($i = 0, $len = strlen($base32); $i < $len; $i++) { + $index = strpos($alphabet, $base32[$i]); + if ($index === false) { + return ''; + } + $buffer = ($buffer << 5) | $index; + $bitsLeft += 5; + if ($bitsLeft >= 8) { + $bitsLeft -= 8; + $out .= chr(($buffer >> $bitsLeft) & 255); + } + } + + return $out; +} + +/** 20 Zufallsbytes = 160 Bit, die von RFC 4226 empfohlene Schluessellaenge. */ +function app_totp_generate_secret(): string +{ + return app_totp_base32_encode(random_bytes(20)); +} + +function app_totp_code(string $secret, int $timeStep): string +{ + $key = app_totp_base32_decode($secret); + if ($key === '') { + return ''; + } + + $counter = pack('J', $timeStep); + $hash = hash_hmac('sha1', $counter, $key, true); + + // Dynamic Truncation nach RFC 4226 Abschnitt 5.3. + $offset = ord($hash[19]) & 0x0f; + $value = ((ord($hash[$offset]) & 0x7f) << 24) + | ((ord($hash[$offset + 1]) & 0xff) << 16) + | ((ord($hash[$offset + 2]) & 0xff) << 8) + | (ord($hash[$offset + 3]) & 0xff); + + return str_pad((string)($value % (10 ** APP_TOTP_DIGITS)), APP_TOTP_DIGITS, '0', STR_PAD_LEFT); +} + +/** + * Prueft den Code gegen das Toleranzfenster und liefert den getroffenen + * Zeitschritt zurueck - den braucht der Aufrufer, um einen einmal benutzten + * Code nicht ein zweites Mal zuzulassen. + */ +function app_totp_verify_code(string $secret, string $code, ?int $now = null): ?int +{ + $code = preg_replace('/\s+/', '', $code) ?? ''; + if (preg_match('/^\d{' . APP_TOTP_DIGITS . '}$/', $code) !== 1) { + return null; + } + + $currentStep = intdiv($now ?? time(), APP_TOTP_PERIOD); + for ($offset = -APP_TOTP_WINDOW; $offset <= APP_TOTP_WINDOW; $offset++) { + $step = $currentStep + $offset; + $expected = app_totp_code($secret, $step); + if ($expected !== '' && hash_equals($expected, $code)) { + return $step; + } + } + + return null; +} + +/** + * otpauth-URI nach der Key-Uri-Format-Spezifikation. Der Label-Teil traegt + * Aussteller und Konto, damit in der App nicht zehn namenlose Eintraege + * stehen. + */ +function app_totp_provisioning_uri(string $secret, string $accountName): string +{ + $issuer = 'Kaffeeliste'; + + return 'otpauth://totp/' + . rawurlencode($issuer) . ':' . rawurlencode($accountName) + . '?' . http_build_query([ + 'secret' => $secret, + 'issuer' => $issuer, + 'algorithm' => 'SHA1', + 'digits' => APP_TOTP_DIGITS, + 'period' => APP_TOTP_PERIOD, + ], '', '&', PHP_QUERY_RFC3986); +} + +/** + * QR-Code als eingebettetes SVG. TCPDF liefert ein vollstaendiges Dokument + * samt XML-Deklaration; fuer die Einbettung in eine HTML-Seite bleibt nur + * das svg-Element uebrig. + */ +function app_totp_qr_svg(string $uri): string +{ + require_once __DIR__ . '/../TCPDF/tcpdf_barcodes_2d.php'; + + $barcode = new TCPDF2DBarcode($uri, 'QRCODE,M'); + $svg = $barcode->getBarcodeSVGcode(4, 4, 'black'); + $start = strpos($svg, ' + */ +function app_totp_generate_recovery_codes(int $count = APP_TOTP_RECOVERY_CODE_COUNT): array +{ + $alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; + $max = strlen($alphabet) - 1; + $codes = []; + + for ($i = 0; $i < $count; $i++) { + $code = ''; + for ($c = 0; $c < 8; $c++) { + if ($c === 4) { + $code .= '-'; + } + $code .= $alphabet[random_int(0, $max)]; + } + $codes[] = $code; + } + + return $codes; +} + +function app_totp_normalize_recovery_code(string $code): string +{ + return strtolower(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? ''); +} + +// --------------------------------------------------------------------------- +// Persistenz +// --------------------------------------------------------------------------- + +/** + * Ist der zweite Faktor fuer dieses Konto scharf? Ein angefangenes, aber nie + * bestaetigtes Geheimnis zaehlt nicht - sonst sperrte sich jemand aus, der + * die Einrichtung auf halbem Weg abbricht. + */ +function app_totp_is_active(PDO $pdo, int $userId): bool +{ + $stmt = $pdo->prepare('SELECT totp_confirmed_at FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $confirmedAt = $stmt->fetchColumn(); + + return $confirmedAt !== false && $confirmedAt !== null; +} + +/** + * @return array{secret: ?string, confirmed_at: ?string, last_step: ?int} + */ +function app_totp_state(PDO $pdo, int $userId): array +{ + $stmt = $pdo->prepare('SELECT totp_secret, totp_confirmed_at, totp_last_step FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $row = $stmt->fetch(); + + if ($row === false) { + return ['secret' => null, 'confirmed_at' => null, 'last_step' => null]; + } + + return [ + 'secret' => $row['totp_secret'] !== null ? (string)$row['totp_secret'] : null, + 'confirmed_at' => $row['totp_confirmed_at'] !== null ? (string)$row['totp_confirmed_at'] : null, + 'last_step' => $row['totp_last_step'] !== null ? (int)$row['totp_last_step'] : null, + ]; +} + +/** + * Legt ein frisches, noch unbestaetigtes Geheimnis an. Ein bereits scharfer + * zweiter Faktor wird dabei nicht angetastet - abschalten laeuft + * ausschliesslich ueber app_totp_disable(). + */ +function app_totp_begin_setup(PDO $pdo, int $userId): ?string +{ + if (app_totp_is_active($pdo, $userId)) { + return null; + } + + $secret = app_totp_generate_secret(); + $pdo->prepare('UPDATE users SET totp_secret = ?, totp_confirmed_at = NULL, totp_last_step = NULL WHERE id = ?') + ->execute([$secret, $userId]); + + return $secret; +} + +/** + * Schaltet den zweiten Faktor scharf, nachdem ein gueltiger Code aus der App + * kam, und gibt die Wiederherstellungscodes im Klartext zurueck - das ist + * der einzige Moment, in dem sie sichtbar sind. Gespeichert werden nur ihre + * Hashes. + * + * @return list|null Klartext-Codes, oder null bei falschem Code. + */ +function app_totp_confirm_setup(PDO $pdo, int $userId, string $code): ?array +{ + $state = app_totp_state($pdo, $userId); + if ($state['secret'] === null || $state['confirmed_at'] !== null) { + return null; + } + + $step = app_totp_verify_code($state['secret'], $code); + if ($step === null) { + return null; + } + + $codes = app_totp_generate_recovery_codes(); + + $pdo->beginTransaction(); + try { + $pdo->prepare('UPDATE users SET totp_confirmed_at = NOW(), totp_last_step = ? WHERE id = ?') + ->execute([$step, $userId]); + $pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]); + + $insert = $pdo->prepare('INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES (?, ?)'); + foreach ($codes as $plain) { + $insert->execute([$userId, password_hash(app_totp_normalize_recovery_code($plain), PASSWORD_DEFAULT)]); + } + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + throw $e; + } + + return $codes; +} + +/** + * Prueft einen Code aus der App. Ein bereits verwendeter Zeitschritt wird + * abgelehnt: sonst liesse sich ein einmal abgefangener Code innerhalb seines + * Gueltigkeitsfensters ein zweites Mal einloesen. + */ +function app_totp_verify_for_user(PDO $pdo, int $userId, string $code): bool +{ + $state = app_totp_state($pdo, $userId); + if ($state['secret'] === null || $state['confirmed_at'] === null) { + return false; + } + + $step = app_totp_verify_code($state['secret'], $code); + if ($step === null) { + return false; + } + if ($state['last_step'] !== null && $step <= $state['last_step']) { + return false; + } + + $pdo->prepare('UPDATE users SET totp_last_step = ? WHERE id = ?')->execute([$step, $userId]); + + return true; +} + +/** + * Loest einen Wiederherstellungscode ein. Jeder Code gilt genau einmal. + */ +function app_totp_consume_recovery_code(PDO $pdo, int $userId, string $code): bool +{ + $normalized = app_totp_normalize_recovery_code($code); + if ($normalized === '') { + return false; + } + + $stmt = $pdo->prepare('SELECT id, code_hash FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL'); + $stmt->execute([$userId]); + + foreach ($stmt->fetchAll() as $row) { + if (password_verify($normalized, (string)$row['code_hash'])) { + $pdo->prepare('UPDATE totp_recovery_codes SET used_at = NOW() WHERE id = ? AND used_at IS NULL') + ->execute([(int)$row['id']]); + + return true; + } + } + + return false; +} + +function app_totp_unused_recovery_code_count(PDO $pdo, int $userId): int +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL'); + $stmt->execute([$userId]); + + return (int)$stmt->fetchColumn(); +} + +function app_totp_disable(PDO $pdo, int $userId): void +{ + $pdo->beginTransaction(); + try { + $pdo->prepare('UPDATE users SET totp_secret = NULL, totp_confirmed_at = NULL, totp_last_step = NULL WHERE id = ?') + ->execute([$userId]); + $pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]); + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + throw $e; + } +} + +// --------------------------------------------------------------------------- +// Angefangene Anmeldung (Passwort stimmt, zweiter Faktor fehlt noch) +// --------------------------------------------------------------------------- + +/** + * Zwischenzustand nach richtigem Passwort. Bewusst nach demselben Muster wie + * saas_start_pending_tenant_selection(): Session-ID erneuern und alle + * Login-Schluessel loeschen, damit dieser Zustand fuer sich genommen + * keinerlei Zugriff gewaehrt. + */ +function app_totp_start_challenge(int $userId): void +{ + app_start_session(); + session_regenerate_id(true); + + $_SESSION['totp_pending_user_id'] = $userId; + $_SESSION['totp_pending_started_at'] = time(); + unset( + $_SESSION['saas_user_id'], + $_SESSION['saas_tenant_id'], + $_SESSION['saas_role'], + $_SESSION['saas_pending_user_id'], + $_SESSION['saas_pending_started_at'] + ); +} + +function app_totp_challenge_user_id(): ?int +{ + app_start_session(); + + $userId = isset($_SESSION['totp_pending_user_id']) ? (int)$_SESSION['totp_pending_user_id'] : 0; + $startedAt = isset($_SESSION['totp_pending_started_at']) ? (int)$_SESSION['totp_pending_started_at'] : 0; + if ($userId <= 0 || $startedAt <= 0 || $startedAt < time() - APP_TOTP_CHALLENGE_TTL) { + app_totp_clear_challenge(); + + return null; + } + + return $userId; +} + +function app_totp_clear_challenge(): void +{ + app_start_session(); + unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_started_at']); +} + +/** + * Muss dieses Konto zwingend einen zweiten Faktor haben? Standardmaessig + * nein - der Schalter existiert, damit der Betreiber ihn erst umlegt, + * nachdem er seine eigene Einrichtung erfolgreich getestet hat. Andernfalls + * koennte ein Fehler im Einrichtungsweg den einzigen Platform-Admin vom + * Back-Office aussperren. + */ +function app_totp_required_for_platform_admins(): bool +{ + return app_env('APP_REQUIRE_2FA_FOR_ADMINS', '0') === '1'; +} + +/** + * Schliesst eine Anmeldung ab, deren zweiter Faktor bestaetigt ist. Die + * Mandantenlage wird dabei frisch aus der Datenbank gelesen statt aus dem + * Zwischenzustand uebernommen - zwischen Passwort und Code koennen Minuten + * liegen, in denen sich Mitgliedschaften geaendert haben. + */ +function app_totp_finish_login(PDO $pdo, int $userId): void +{ + app_totp_clear_challenge(); + $memberships = saas_list_user_memberships($pdo, $userId); + + if (count($memberships) > 1) { + saas_start_pending_tenant_selection($userId); + header('Location: mandant-auswahl.php'); + exit; + } + + $identity = $memberships !== [] + ? saas_identity_for_user_tenant($pdo, $userId, (int)$memberships[0]['tenant_id']) + : null; + + if ($identity === null) { + header('Location: login.php'); + exit; + } + + saas_session_login($identity); + header('Location: index.php'); + exit; +} diff --git a/database/migrations/0031_totp_two_factor.sql b/database/migrations/0031_totp_two_factor.sql new file mode 100644 index 0000000..2203045 --- /dev/null +++ b/database/migrations/0031_totp_two_factor.sql @@ -0,0 +1,28 @@ +-- Zweiter Faktor per TOTP (RFC 6238). +-- +-- totp_secret haelt das Base32-Geheimnis. Es steht schon vor der Bestaetigung +-- in der Zeile, damit der eingegebene Code dagegen geprueft werden kann; +-- scharf ist der zweite Faktor aber erst mit totp_confirmed_at. Wer die +-- Einrichtung abbricht, sperrt sich damit nicht aus. +-- +-- totp_last_step merkt sich den zuletzt eingeloesten Zeitschritt. Ohne das +-- liesse sich ein abgefangener Code innerhalb seines Gueltigkeitsfensters +-- ein zweites Mal verwenden. +ALTER TABLE users + ADD COLUMN totp_secret VARCHAR(64) NULL AFTER password_hash, + ADD COLUMN totp_confirmed_at DATETIME NULL AFTER totp_secret, + ADD COLUMN totp_last_step BIGINT NULL AFTER totp_confirmed_at; + +-- Wiederherstellungscodes fuer den Fall, dass das Telefon verloren geht. +-- Gespeichert wird nur der Hash: im Klartext sind die Codes genau einmal +-- sichtbar, direkt nach der Einrichtung. +CREATE TABLE IF NOT EXISTS totp_recovery_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + code_hash VARCHAR(255) NOT NULL, + used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_totp_recovery_codes_user (user_id, used_at), + CONSTRAINT fk_totp_recovery_codes_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/deployment.md b/docs/deployment.md index 2dac712..35fa91d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -266,6 +266,49 @@ Stripe-Flow zur Aktualisierung der Zahlungsart. Im Stripe-Dashboard dürfen der zusätzlich freigeschaltet werden; Bestellungen und Kündigungen sollen nur über die dokumentierten Abläufe der Kaffeeliste erfolgen. +## Zwei-Faktor-Anmeldung + +Jedes Konto kann unter `zwei-faktor-einrichten.php` (verlinkt aus +`konto.php`) einen zweiten Faktor per TOTP einrichten — dieselben +sechsstelligen Codes, die Aegis, 2FAS, Google Authenticator oder ein +Passwortmanager erzeugen. + +`app/totp.php` implementiert RFC 6238 selbst, statt eine Bibliothek zu +ziehen: der Algorithmus ist ein HMAC plus eine Truncation, und ein zweiter +Faktor ist die letzte Stelle, an der man ungeprüfte Abhängigkeiten haben +will. Der QR-Code entsteht aus dem ohnehin vorhandenen TCPDF — kein +externer Dienst bekommt das Geheimnis zu sehen. + +Ablauf beim Login: stimmt das Passwort und ist ein zweiter Faktor scharf, +wird die Anmeldung **nicht** abgeschlossen. Es entsteht nur ein +Zwischenzustand (`totp_pending_user_id`, 15 Minuten gültig), der für sich +genommen keinerlei Zugriff gewährt; erst der Code stellt die Sitzung her. +Wer mehrere Mandanten hat, kommt auch erst danach zur Mandantenauswahl. + +Weitere Festlegungen: + +- **Wiederherstellungscodes**: zehn Stück, nur direkt nach der Einrichtung + im Klartext sichtbar, gespeichert als Hash, jeder genau einmal gültig. + Auf der Code-Seite genügt es, statt des App-Codes einen davon einzugeben — + Buchstaben im Feld unterscheiden die beiden Fälle. +- **Wiederverwendung ausgeschlossen**: `users.totp_last_step` merkt sich den + zuletzt eingelösten Zeitschritt. Ohne das ließe sich ein abgefangener Code + innerhalb seines Gültigkeitsfensters ein zweites Mal verwenden. +- **Toleranz** von einem Zeitschritt (±30 s) für Uhrenabweichungen. +- **Rate-Limit** auf der Code-Eingabe: 5 Versuche pro Konto und 20 pro IP je + 15 Minuten — sechs Ziffern sind sonst schnell durchprobiert. +- **Abschalten** verlangt Passwort *und* gültigen Code. + +`APP_REQUIRE_2FA_FOR_ADMINS=1` macht den zweiten Faktor für Platform-Admins +verbindlich: das Back-Office leitet dann auf die Einrichtung um, statt hart +zu sperren — sonst könnte der Schalter den einzigen Platform-Admin dauerhaft +aussperren. **Erst umlegen, nachdem die eigene Einrichtung getestet ist.** + +Abgedeckt von `scripts/check-totp-flow.php` (Algorithmus inklusive der +RFC-6238-Testvektoren, Persistenz, Wiederherstellungscodes) und +`scripts/check-2fa-http-flow.php` (Anmeldeweg; braucht einen laufenden +Webserver). + ## Schutz der offenen Registrierung Ab Juli 2026 liefen auf `testumgebung.kaffeeliste.de` täglich 10–20 diff --git a/env.local.example.php b/env.local.example.php index 2f03730..d77c3d4 100644 --- a/env.local.example.php +++ b/env.local.example.php @@ -88,3 +88,8 @@ putenv('PAYPAL_IMAP_MAILBOX=INBOX'); // Fehlt einer der beiden, ist die Benachrichtigung still deaktiviert. putenv('PUSHOVER_TOKEN='); putenv('PUSHOVER_USER='); + +// Zwei-Faktor-Anmeldung: fuer alle Konten freiwillig einrichtbar. Auf '1' +// gesetzt, muessen Platform-Admins (Back-Office) zwingend einen zweiten +// Faktor haben - erst umlegen, nachdem die eigene Einrichtung getestet ist. +putenv('APP_REQUIRE_2FA_FOR_ADMINS=0'); diff --git a/konto.php b/konto.php index 8962dd2..c872aff 100644 --- a/konto.php +++ b/konto.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/app/features.php'; +require_once __DIR__ . '/app/totp.php'; $user = saas_require_login(); $devVerificationToken = null; @@ -90,6 +91,11 @@ include 'nav.php'; E-Mail bestätigt + + Zwei-Faktor-Anmeldung + + – verwalten + diff --git a/login.php b/login.php index 05fa6ab..aefcafa 100644 --- a/login.php +++ b/login.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/app/rate-limit.php'; +require_once __DIR__ . '/app/totp.php'; $pdo = app_db_pdo(); $errors = []; @@ -25,6 +26,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { (string)($_POST['password'] ?? '') ); + // Stimmt das Passwort und ist ein zweiter Faktor scharf, wird die + // Anmeldung hier nicht abgeschlossen: es entsteht nur ein + // Zwischenzustand ohne jeden Zugriff. Das gilt fuer beide Faelle - + // auch wer noch einen Mandanten auswaehlen muesste, kommt erst nach + // dem Code dorthin. + if ($result['ok']) { + $totpUserId = (int)($result['user_id'] ?? $result['identity']['user_id'] ?? 0); + if ($totpUserId > 0 && app_totp_is_active($pdo, $totpUserId)) { + app_totp_start_challenge($totpUserId); + header('Location: zwei-faktor.php'); + exit; + } + } + if ($result['ok'] && !empty($result['needs_tenant_selection'])) { saas_start_pending_tenant_selection((int)$result['user_id']); header('Location: mandant-auswahl.php'); diff --git a/scripts/check-2fa-http-flow.php b/scripts/check-2fa-http-flow.php new file mode 100644 index 0000000..7fc7748 --- /dev/null +++ b/scripts/check-2fa-http-flow.php @@ -0,0 +1,209 @@ + $jar + * @return array{status: int, location: ?string, body: string} + */ +function zfa_request(string $url, array &$jar, string $method = 'GET', ?string $body = null): array +{ + $headers = ['Content-Type: application/x-www-form-urlencoded']; + if ($jar !== []) { + $pairs = []; + foreach ($jar as $name => $value) { + $pairs[] = "{$name}={$value}"; + } + $headers[] = 'Cookie: ' . implode('; ', $pairs); + } + + $context = stream_context_create(['http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'timeout' => 15, + 'ignore_errors' => true, + 'follow_location' => 0, + ]]); + + $responseBody = @file_get_contents($url, false, $context); + $status = 0; + $location = null; + + foreach ($http_response_header ?? [] as $header) { + if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) { + $status = (int)$m[1]; + } + if (stripos($header, 'Location:') === 0) { + $location = trim(substr($header, 9)); + } + if (stripos($header, 'Set-Cookie:') === 0 && preg_match('/^Set-Cookie:\s*([^=]+)=([^;]*)/i', $header, $m) === 1) { + $jar[$m[1]] = $m[2]; + } + } + + return ['status' => $status, 'location' => $location, 'body' => (string)$responseBody]; +} + +/** @param array $jar */ +function zfa_csrf(string $url, array &$jar): string +{ + $response = zfa_request($url, $jar); + preg_match('/name="csrf_token" value="([^"]+)"/', $response['body'], $m); + + return $m[1] ?? ''; +} + +$failures = []; +$passes = 0; + +function zfa_assert(string $label, bool $condition, array &$failures, int &$passes): void +{ + if ($condition) { + $passes++; + echo "PASS {$label}\n"; + return; + } + + $failures[] = $label; + echo "FAIL {$label}\n"; +} + +$probe = []; +$reachable = zfa_request("{$baseUrl}/login.php", $probe); +if ($reachable['status'] !== 200) { + fwrite(STDERR, "Kein Webserver unter {$baseUrl} erreichbar. Siehe Kopf dieser Datei.\n"); + exit(1); +} + +$pdo = dev_pdo(); +$suffix = bin2hex(random_bytes(4)); +$email = "zfacheck-{$suffix}@example.com"; +$password = 'geheim-genug-123'; + +$registration = saas_register_tenant_owner($pdo, [ + 'tenant_name' => "2FA Check {$suffix}", + 'tenant_slug' => "zfacheck-{$suffix}", + 'display_name' => '2FA Checker', + 'email' => $email, + 'password' => $password, + 'password_confirm' => $password, + 'customer_type' => 'business', + 'accept_terms' => true, + 'acknowledge_privacy' => true, + 'accept_dpa' => true, +]); + +if (empty($registration['ok'])) { + fwrite(STDERR, 'Setup fehlgeschlagen: ' . implode('; ', $registration['errors'] ?? []) . "\n"); + exit(1); +} + +$userId = (int)$registration['identity']['user_id']; +$tenantId = (int)$registration['identity']['tenant_id']; + +try { + // Ausgangslage: ohne zweiten Faktor fuehrt der Login direkt weiter. + $jar = []; + $csrf = zfa_csrf("{$baseUrl}/login.php", $jar); + $login = zfa_request("{$baseUrl}/login.php", $jar, 'POST', http_build_query([ + 'csrf_token' => $csrf, 'email' => $email, 'password' => $password, + ])); + zfa_assert('Ohne zweiten Faktor fuehrt der Login direkt auf index.php', $login['location'] === 'index.php', $failures, $passes); + + $secret = (string)app_totp_begin_setup($pdo, $userId); + app_totp_confirm_setup($pdo, $userId, app_totp_code($secret, intdiv(time(), APP_TOTP_PERIOD))); + + // Ab hier ist der zweite Faktor scharf. + $jar = []; + $csrf = zfa_csrf("{$baseUrl}/login.php", $jar); + $login = zfa_request("{$baseUrl}/login.php", $jar, 'POST', http_build_query([ + 'csrf_token' => $csrf, 'email' => $email, 'password' => $password, + ])); + zfa_assert('Mit zweitem Faktor fuehrt der Login auf zwei-faktor.php', $login['location'] === 'zwei-faktor.php', $failures, $passes); + + // Der Zwischenzustand darf sich in nichts von "gar nicht angemeldet" + // unterscheiden - das ist der Kern der ganzen Uebung. + $pending = zfa_request("{$baseUrl}/index.php", $jar); + $anonymousJar = []; + $anonymous = zfa_request("{$baseUrl}/index.php", $anonymousJar); + zfa_assert( + 'Der Zwischenzustand liefert dasselbe wie ein unangemeldeter Aufruf', + $pending['status'] === $anonymous['status'] && strlen($pending['body']) === strlen($anonymous['body']), + $failures, + $passes + ); + zfa_assert( + 'Der Zwischenzustand zeigt keine Mandantendaten', + !str_contains($pending['body'], "2FA Check {$suffix}"), + $failures, + $passes + ); + + $csrf = zfa_csrf("{$baseUrl}/zwei-faktor.php", $jar); + $wrong = zfa_request("{$baseUrl}/zwei-faktor.php", $jar, 'POST', http_build_query([ + 'csrf_token' => $csrf, 'code' => '000000', + ])); + zfa_assert( + 'Falscher Code wird abgewiesen', + $wrong['location'] === null && str_contains($wrong['body'], 'stimmt nicht'), + $failures, + $passes + ); + + // Ein Zeitschritt weiter, weil der Einrichtungscode schon verbraucht ist. + $csrf = zfa_csrf("{$baseUrl}/zwei-faktor.php", $jar); + $right = zfa_request("{$baseUrl}/zwei-faktor.php", $jar, 'POST', http_build_query([ + 'csrf_token' => $csrf, + 'code' => app_totp_code($secret, intdiv(time(), APP_TOTP_PERIOD) + 1), + ])); + zfa_assert('Richtiger Code schliesst die Anmeldung ab', $right['location'] === 'index.php', $failures, $passes); + + $dashboard = zfa_request("{$baseUrl}/index.php", $jar); + zfa_assert('Nach dem Code ist das Dashboard erreichbar', $dashboard['status'] === 200, $failures, $passes); + + $strangerJar = []; + $stranger = zfa_request("{$baseUrl}/zwei-faktor.php", $strangerJar); + zfa_assert( + 'zwei-faktor.php ohne angefangene Anmeldung leitet zum Login', + $stranger['location'] === 'login.php', + $failures, + $passes + ); +} finally { + $pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket LIKE ?')->execute(['2fa_%']); +} + +echo "\n"; +if ($failures !== []) { + fwrite(STDERR, '2FA-HTTP-Check fehlgeschlagen: ' . implode('; ', $failures) . "\n"); + exit(1); +} + +echo "2FA-HTTP-Check bestanden mit {$passes} Zusicherungen.\n"; diff --git a/scripts/check-konto-und-mandantenwechsel.php b/scripts/check-konto-und-mandantenwechsel.php index dbeafbb..76911ec 100644 --- a/scripts/check-konto-und-mandantenwechsel.php +++ b/scripts/check-konto-und-mandantenwechsel.php @@ -169,13 +169,21 @@ try { // --------------------------------------------------------------- $cookies = []; $csrf = konto_csrf("{$baseUrl}/login.php", $cookies); + // Das Login-Formular kennt kein Kundenkuerzel mehr: wer bei mehreren + // Mandanten Mitglied ist, waehlt nach dem Passwort per Klarnamen aus. $login = konto_request("{$baseUrl}/login.php", $cookies, 'POST', http_build_query([ 'csrf_token' => $csrf, 'email' => "kontocheck-{$suffix}@test.local", 'password' => $altesPasswort, - 'tenant_slug' => "kontocheck-a-{$suffix}", ])); - pruefe('Login leitet auf index.php statt konto.php', $login['location'] === 'index.php'); + pruefe('Login mit mehreren Mandanten fuehrt zur Mandantenauswahl', $login['location'] === 'mandant-auswahl.php'); + + $csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies); + $ersteWahl = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([ + 'csrf_token' => $csrf, + 'tenant_id' => (string)$tenantAId, + ])); + pruefe('Mandantenwahl nach dem Login leitet auf index.php statt konto.php', $ersteWahl['location'] === 'index.php'); // --------------------------------------------------------------- // 3. Verifikation wird fuer Einladungen erzwungen @@ -281,7 +289,6 @@ try { 'csrf_token' => $csrf, 'email' => "kontocheck-{$suffix}@test.local", 'password' => $altesPasswort, - 'tenant_slug' => "kontocheck-a-{$suffix}", ])); pruefe('Login mit altem Passwort schlaegt fehl', $altLogin['location'] === null); @@ -291,9 +298,8 @@ try { 'csrf_token' => $csrf, 'email' => "kontocheck-{$suffix}@test.local", 'password' => $neuesPasswort, - 'tenant_slug' => "kontocheck-a-{$suffix}", ])); - pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'index.php'); + pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'mandant-auswahl.php'); // --------------------------------------------------------------- // Reset-Link bestaetigt die Adresse mit diff --git a/scripts/check-totp-flow.php b/scripts/check-totp-flow.php new file mode 100644 index 0000000..be66b3e --- /dev/null +++ b/scripts/check-totp-flow.php @@ -0,0 +1,264 @@ + '287082', + 1111111109 => '081804', + 1111111111 => '050471', + 1234567890 => '005924', + 2000000000 => '279037', +]; +foreach ($vectors as $timestamp => $expected) { + totp_check_assert( + "RFC-6238-Testvektor bei t={$timestamp}", + app_totp_code($rfcSecret, intdiv($timestamp, APP_TOTP_PERIOD)) === $expected, + $failures, + $passes + ); +} + +// --- Base32 hin und zurueck --------------------------------------------- +$binary = random_bytes(20); +totp_check_assert( + 'Base32 kodiert und dekodiert verlustfrei', + app_totp_base32_decode(app_totp_base32_encode($binary)) === $binary, + $failures, + $passes +); +totp_check_assert( + 'Von Hand abgetipptes Geheimnis mit Leerzeichen wird verstanden', + app_totp_base32_decode(trim(chunk_split(app_totp_base32_encode($binary), 4, ' '))) === $binary, + $failures, + $passes +); + +// --- Toleranzfenster ----------------------------------------------------- +$secret = app_totp_generate_secret(); +$now = time(); +$step = intdiv($now, APP_TOTP_PERIOD); + +totp_check_assert( + 'Aktueller Code wird angenommen', + app_totp_verify_code($secret, app_totp_code($secret, $step), $now) === $step, + $failures, + $passes +); +totp_check_assert( + 'Code des vorigen Zeitschritts wird noch angenommen', + app_totp_verify_code($secret, app_totp_code($secret, $step - 1), $now) === $step - 1, + $failures, + $passes +); +totp_check_assert( + 'Zu alter Code wird abgelehnt', + app_totp_verify_code($secret, app_totp_code($secret, $step - 5), $now) === null, + $failures, + $passes +); +totp_check_assert( + 'Falscher Code wird abgelehnt', + app_totp_verify_code($secret, '000000', $now) === null || app_totp_code($secret, $step) === '000000', + $failures, + $passes +); +totp_check_assert( + 'Nicht-numerische Eingabe wird abgelehnt', + app_totp_verify_code($secret, 'abcdef', $now) === null, + $failures, + $passes +); + +// --- QR-Code und URI ----------------------------------------------------- +$uri = app_totp_provisioning_uri($secret, 'test@example.com'); +totp_check_assert( + 'Provisioning-URI traegt Geheimnis und Aussteller', + str_starts_with($uri, 'otpauth://totp/') && str_contains($uri, 'secret=' . $secret) && str_contains($uri, 'issuer=Kaffeeliste'), + $failures, + $passes +); +$svg = app_totp_qr_svg($uri); +totp_check_assert( + 'QR-Code entsteht als einbettbares SVG', + str_starts_with($svg, ' "TOTP Check {$suffix}", + 'tenant_slug' => $slug, + 'display_name' => 'TOTP Checker', + 'email' => $email, + 'password' => 'geheim-genug-123', + 'password_confirm' => 'geheim-genug-123', + 'customer_type' => 'business', + 'accept_terms' => true, + 'acknowledge_privacy' => true, + 'accept_dpa' => true, +]); + +if (empty($registration['ok'])) { + fwrite(STDERR, "Setup fehlgeschlagen: " . implode('; ', $registration['errors'] ?? []) . "\n"); + exit(1); +} + +$userId = (int)$registration['identity']['user_id']; +$tenantId = (int)$registration['identity']['tenant_id']; + +try { + totp_check_assert('Frisches Konto hat keinen zweiten Faktor', !app_totp_is_active($pdo, $userId), $failures, $passes); + + $setupSecret = app_totp_begin_setup($pdo, $userId); + totp_check_assert('Einrichtung liefert ein Geheimnis', is_string($setupSecret) && $setupSecret !== '', $failures, $passes); + totp_check_assert( + 'Angefangene Einrichtung ist noch nicht scharf', + !app_totp_is_active($pdo, $userId), + $failures, + $passes + ); + + totp_check_assert( + 'Bestaetigung mit falschem Code schlaegt fehl', + app_totp_confirm_setup($pdo, $userId, '000000') === null + || app_totp_code((string)$setupSecret, intdiv(time(), APP_TOTP_PERIOD)) === '000000', + $failures, + $passes + ); + + $currentStep = intdiv(time(), APP_TOTP_PERIOD); + $recoveryCodes = app_totp_confirm_setup($pdo, $userId, app_totp_code((string)$setupSecret, $currentStep)); + totp_check_assert( + 'Bestaetigung mit richtigem Code liefert Wiederherstellungscodes', + is_array($recoveryCodes) && count($recoveryCodes) === APP_TOTP_RECOVERY_CODE_COUNT, + $failures, + $passes + ); + totp_check_assert('Zweiter Faktor ist jetzt aktiv', app_totp_is_active($pdo, $userId), $failures, $passes); + + // Der bei der Einrichtung benutzte Zeitschritt darf nicht noch einmal gehen. + totp_check_assert( + 'Bereits eingeloester Code wird nicht wiederverwendet', + !app_totp_verify_for_user($pdo, $userId, app_totp_code((string)$setupSecret, $currentStep)), + $failures, + $passes + ); + + totp_check_assert( + 'Naechster Code wird angenommen', + app_totp_verify_for_user($pdo, $userId, app_totp_code((string)$setupSecret, $currentStep + 1)), + $failures, + $passes + ); + + // --- Wiederherstellungscodes --- + $codes = (array)$recoveryCodes; + totp_check_assert( + 'Alle Wiederherstellungscodes sind zunaechst unbenutzt', + app_totp_unused_recovery_code_count($pdo, $userId) === APP_TOTP_RECOVERY_CODE_COUNT, + $failures, + $passes + ); + totp_check_assert( + 'Wiederherstellungscode wird angenommen', + app_totp_consume_recovery_code($pdo, $userId, (string)$codes[0]), + $failures, + $passes + ); + totp_check_assert( + 'Derselbe Wiederherstellungscode gilt kein zweites Mal', + !app_totp_consume_recovery_code($pdo, $userId, (string)$codes[0]), + $failures, + $passes + ); + totp_check_assert( + 'Wiederherstellungscode wird auch mit Grossbuchstaben und ohne Bindestrich erkannt', + app_totp_consume_recovery_code($pdo, $userId, strtoupper(str_replace('-', '', (string)$codes[1]))), + $failures, + $passes + ); + totp_check_assert( + 'Erfundener Wiederherstellungscode wird abgelehnt', + !app_totp_consume_recovery_code($pdo, $userId, 'zzzz-zzzz'), + $failures, + $passes + ); + totp_check_assert( + 'Verbrauchte Codes sind abgezogen', + app_totp_unused_recovery_code_count($pdo, $userId) === APP_TOTP_RECOVERY_CODE_COUNT - 2, + $failures, + $passes + ); + + // --- Passwortbestaetigung und Abschalten --- + totp_check_assert( + 'Richtiges Passwort wird bestaetigt', + saas_password_matches($pdo, $userId, 'geheim-genug-123'), + $failures, + $passes + ); + totp_check_assert( + 'Falsches Passwort wird abgelehnt', + !saas_password_matches($pdo, $userId, 'falsch'), + $failures, + $passes + ); + + app_totp_disable($pdo, $userId); + totp_check_assert('Abschalten deaktiviert den zweiten Faktor', !app_totp_is_active($pdo, $userId), $failures, $passes); + totp_check_assert( + 'Abschalten entfernt die Wiederherstellungscodes', + app_totp_unused_recovery_code_count($pdo, $userId) === 0, + $failures, + $passes + ); +} finally { + $pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]); +} + +echo "\n"; +if ($failures !== []) { + fwrite(STDERR, 'TOTP-Check fehlgeschlagen: ' . implode('; ', $failures) . "\n"); + exit(1); +} + +echo "TOTP-Check bestanden mit {$passes} Zusicherungen.\n"; diff --git a/zwei-faktor-einrichten.php b/zwei-faktor-einrichten.php new file mode 100644 index 0000000..f5ec068 --- /dev/null +++ b/zwei-faktor-einrichten.php @@ -0,0 +1,146 @@ + + + + + diff --git a/zwei-faktor.php b/zwei-faktor.php new file mode 100644 index 0000000..5598d9c --- /dev/null +++ b/zwei-faktor.php @@ -0,0 +1,116 @@ + + + + + + Kaffeeliste Bestätigung + + + + + + +
+
+ + +
+
+

Bestätigung

+

Dein Konto ist mit einem zweiten Faktor geschützt. Gib den sechsstelligen Code + aus deiner Authenticator-App ein.

+
+ +
+

Zur App

+ + +
+ +

+ +
+ + +
+ +
+
+ + +
+
+
    +
  • +
+
+ +

Kein Zugriff auf die App? Gib stattdessen einen deiner + Wiederherstellungscodes ein.

+
+
+
+
+ + + +