Files
kaffeekasse-saas/scripts/check-2fa-http-flow.php
T
clemensandClaude Opus 5 a2dcc4df65 Zwei-Faktor-Anmeldung per TOTP
app/totp.php implementiert RFC 6238 selbst statt per Bibliothek: der
Algorithmus ist ein HMAC plus eine Truncation, und ein zweiter Faktor ist
die letzte Stelle fuer ungepruefte Abhaengigkeiten. Der QR-Code entsteht
aus dem ohnehin vorhandenen TCPDF, damit kein externer Dienst das
Geheimnis sieht.

Beim Login wird die Anmeldung bei aktivem zweitem Faktor nicht
abgeschlossen; der Zwischenzustand gewaehrt keinerlei Zugriff und ist
byte-identisch zu einem unangemeldeten Aufruf. users.totp_last_step
verhindert die Wiederverwendung eines abgefangenen Codes innerhalb seines
Gueltigkeitsfensters. Abschalten verlangt Passwort und Code.

APP_REQUIRE_2FA_FOR_ADMINS macht den Faktor fuer Platform-Admins
verbindlich, per Weiterleitung auf die Einrichtung statt als harte Sperre
- sonst koennte der Schalter den einzigen Admin aussperren. Standard aus.

check-konto-und-mandantenwechsel erwartete beim Login noch das entfernte
Kundenkuerzel-Feld und damit einen direkten Sprung aufs Dashboard; der
Check bildet jetzt den tatsaechlichen Weg ueber die Mandantenauswahl ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:51:45 +02:00

210 lines
7.4 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prueft den zweiten Faktor am echten Anmeldeweg: dass der Login mit
* aktivem TOTP nicht durchgestellt wird, dass der Zwischenzustand keinerlei
* Zugriff gewaehrt und dass erst der richtige Code die Sitzung herstellt.
*
* Braucht einen laufenden Webserver auf dem Projektverzeichnis:
*
* php -S 127.0.0.1:8080 -t .
* php scripts/check-2fa-http-flow.php
*
* Abweichende Adresse ueber SMOKE_BASE_URL. Die algorithmische Seite deckt
* scripts/check-totp-flow.php ohne Server ab.
*/
require __DIR__ . '/dev-db.php';
require __DIR__ . '/../app/totp.php';
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
/**
* @param array<string, string> $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<string, string> $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";