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>
This commit is contained in:
2026-08-23 23:51:45 +02:00
co-authored by Claude Opus 5
parent 86334c2752
commit a2dcc4df65
14 changed files with 1335 additions and 5 deletions
+209
View File
@@ -0,0 +1,209 @@
<?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";
+11 -5
View File
@@ -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
+264
View File
@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
/**
* Prueft den zweiten Faktor von der Codeerzeugung bis zum Einloesen eines
* Wiederherstellungscodes. Legt dafuer einen Wegwerf-Mandanten an und raeumt
* ihn hinterher wieder ab.
*/
require __DIR__ . '/dev-db.php';
require __DIR__ . '/../app/totp.php';
function totp_check_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
$failures = [];
$passes = 0;
// --- Algorithmus gegen die Testvektoren aus RFC 6238 ---------------------
// Der dortige Schluessel ist die ASCII-Folge "12345678901234567890".
$rfcSecret = app_totp_base32_encode('12345678901234567890');
$vectors = [
59 => '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, '<svg') && !str_contains($svg, '<?xml'),
$failures,
$passes
);
// --- Vollstaendiger Ablauf gegen die Datenbank ---------------------------
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
$slug = "totpcheck-{$suffix}";
$email = "totpcheck-{$suffix}@example.com";
$registration = saas_register_tenant_owner($pdo, [
'tenant_name' => "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";