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>
210 lines
7.4 KiB
PHP
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";
|