= 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; }