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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 -
|
||||
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
|
||||
/**
|
||||
* Zweiter Faktor per TOTP (RFC 6238) - dieselben sechsstelligen Codes, die
|
||||
* Google Authenticator, Aegis, 1Password und Co. erzeugen.
|
||||
*
|
||||
* Bewusst selbst implementiert statt per Bibliothek: der Algorithmus ist ein
|
||||
* HMAC plus eine Truncation, PHP bringt beides mit, und ein zweiter Faktor
|
||||
* ist genau die Stelle, an der man keine ungeprueften Abhaengigkeiten haben
|
||||
* will. Der QR-Code entsteht aus dem ohnehin vorhandenen TCPDF, damit kein
|
||||
* externer Dienst den Klartext des Geheimnisses zu sehen bekommt.
|
||||
*/
|
||||
|
||||
const APP_TOTP_DIGITS = 6;
|
||||
const APP_TOTP_PERIOD = 30;
|
||||
|
||||
// Ein Schritt Toleranz in jede Richtung faengt Uhrenabweichungen zwischen
|
||||
// Telefon und Server ab, ohne das Zeitfenster nennenswert aufzuweiten.
|
||||
const APP_TOTP_WINDOW = 1;
|
||||
|
||||
const APP_TOTP_RECOVERY_CODE_COUNT = 10;
|
||||
|
||||
/** Nach dieser Zeit ist eine angefangene Anmeldung verfallen. */
|
||||
const APP_TOTP_CHALLENGE_TTL = 900;
|
||||
|
||||
function app_totp_base32_encode(string $binary): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$out = '';
|
||||
$buffer = 0;
|
||||
$bitsLeft = 0;
|
||||
|
||||
for ($i = 0, $len = strlen($binary); $i < $len; $i++) {
|
||||
$buffer = ($buffer << 8) | ord($binary[$i]);
|
||||
$bitsLeft += 8;
|
||||
while ($bitsLeft >= 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, '<svg');
|
||||
|
||||
return $start === false ? '' : substr($svg, $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiederherstellungscodes fuer den Fall, dass das Telefon weg ist. Format
|
||||
* "abcd-efgh" aus einem Alphabet ohne verwechselbare Zeichen (kein 0/O,
|
||||
* kein 1/l), weil die Codes von Hand abgeschrieben und wieder eingetippt
|
||||
* werden.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
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<string>|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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
<th>E-Mail bestätigt</th>
|
||||
<td><?php echo $user['email_verified_at'] !== null ? 'ja' : 'nein'; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Zwei-Faktor-Anmeldung</th>
|
||||
<td><?php echo app_totp_is_active(app_db_pdo(), (int)$user['user_id']) ? 'aktiv' : 'nicht eingerichtet'; ?>
|
||||
– <a href="zwei-faktor-einrichten.php">verwalten</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($user['email_verified_at'] === null): ?>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/rate-limit.php';
|
||||
require_once __DIR__ . '/app/totp.php';
|
||||
|
||||
$user = saas_require_login();
|
||||
$userId = (int)$user['user_id'];
|
||||
$pdo = app_db_pdo();
|
||||
|
||||
$errors = [];
|
||||
$notice = null;
|
||||
// Die Klartext-Codes existieren nur fuer die Dauer dieses einen Requests.
|
||||
// Sie werden bewusst nicht in der Session geparkt: ein Reload soll sie nicht
|
||||
// erneut anzeigen, sonst waere die Einmaligkeit nur behauptet.
|
||||
$freshRecoveryCodes = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$action = (string)($_POST['aktion'] ?? '');
|
||||
|
||||
if ($action === 'starten') {
|
||||
if (app_totp_begin_setup($pdo, $userId) === null) {
|
||||
$errors[] = 'Der zweite Faktor ist bereits aktiv.';
|
||||
}
|
||||
} elseif ($action === 'bestaetigen') {
|
||||
// Auch die Einrichtung ist ein Rateversuch gegen sechs Ziffern.
|
||||
if (!app_rate_limit_check($pdo, '2fa_setup:' . $userId, 10, 900)) {
|
||||
$errors[] = 'Zu viele Versuche. Bitte warte einige Minuten.';
|
||||
} else {
|
||||
$freshRecoveryCodes = app_totp_confirm_setup($pdo, $userId, (string)($_POST['code'] ?? ''));
|
||||
if ($freshRecoveryCodes === null) {
|
||||
$errors[] = 'Der Code stimmt nicht. Prüfe die Uhrzeit deines Geräts und versuche es erneut.';
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'abschalten') {
|
||||
// Abschalten verlangt beides: Passwort und einen gueltigen Code.
|
||||
// Wer nur kurz an einem offenen Bildschirm sitzt, soll den Schutz
|
||||
// nicht mit einem Klick entfernen koennen.
|
||||
$passwordOk = saas_password_matches($pdo, $userId, (string)($_POST['passwort'] ?? ''));
|
||||
$codeOk = app_totp_verify_for_user($pdo, $userId, (string)($_POST['code'] ?? ''));
|
||||
|
||||
if ($passwordOk && $codeOk) {
|
||||
app_totp_disable($pdo, $userId);
|
||||
$notice = 'Der zweite Faktor ist abgeschaltet.';
|
||||
} else {
|
||||
$errors[] = 'Passwort oder Code stimmt nicht.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$state = app_totp_state($pdo, $userId);
|
||||
$isActive = $state['confirmed_at'] !== null;
|
||||
$setupSecret = (!$isActive && $state['secret'] !== null) ? $state['secret'] : null;
|
||||
$remainingRecoveryCodes = $isActive ? app_totp_unused_recovery_code_count($pdo, $userId) : 0;
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>Zwei-Faktor-Anmeldung</h2>
|
||||
|
||||
<?php if ($notice !== null): ?>
|
||||
<div class="hint-box success"><p><?php echo saas_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<div class="hint-box error"><p><?php echo saas_html($error); ?></p></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($freshRecoveryCodes !== null): ?>
|
||||
<div class="hint-box success">
|
||||
<p><b>Der zweite Faktor ist jetzt aktiv.</b></p>
|
||||
<p>Bewahre diese Wiederherstellungscodes an einem sicheren Ort auf. Jeder gilt
|
||||
genau einmal und ersetzt den Code aus der App, falls du keinen Zugriff mehr auf
|
||||
dein Gerät hast. <b>Sie werden nur dieses eine Mal angezeigt.</b></p>
|
||||
<p style="font-family: monospace; font-size: 1.1em; line-height: 1.8;">
|
||||
<?php foreach ($freshRecoveryCodes as $code): ?>
|
||||
<?php echo saas_html($code); ?><br>
|
||||
<?php endforeach; ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php elseif ($isActive): ?>
|
||||
<div class="hint-box success">
|
||||
<p>Der zweite Faktor ist <b>aktiv</b>, eingerichtet am
|
||||
<?php echo saas_html($state['confirmed_at']); ?>.</p>
|
||||
<p>Unbenutzte Wiederherstellungscodes: <b><?php echo $remainingRecoveryCodes; ?></b><?php
|
||||
echo $remainingRecoveryCodes === 0
|
||||
? ' – alle aufgebraucht. Schalte den zweiten Faktor einmal ab und wieder an, um neue zu erhalten.'
|
||||
: ''; ?></p>
|
||||
</div>
|
||||
|
||||
<h3>Abschalten</h3>
|
||||
<p>Zum Abschalten brauchst du dein Passwort und einen gültigen Code.</p>
|
||||
<form method="post" action="zwei-faktor-einrichten.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="abschalten">
|
||||
<label for="passwort">Passwort</label>
|
||||
<input type="password" name="passwort" id="passwort" required>
|
||||
<label for="code_ab">Code aus der App</label>
|
||||
<input type="text" name="code" id="code_ab" inputmode="numeric" autocomplete="one-time-code" required>
|
||||
<p><button type="submit">Zweiten Faktor abschalten</button></p>
|
||||
</form>
|
||||
|
||||
<?php elseif ($setupSecret !== null): ?>
|
||||
<p>Scanne den Code mit deiner Authenticator-App – zum Beispiel Aegis, 2FAS,
|
||||
Google Authenticator oder dem Passwortmanager deiner Wahl. Gib danach den
|
||||
angezeigten sechsstelligen Code ein, damit die Einrichtung abgeschlossen wird.</p>
|
||||
|
||||
<div style="margin: 1.5em 0; max-width: 220px;">
|
||||
<?php echo app_totp_qr_svg(app_totp_provisioning_uri($setupSecret, (string)$user['email'])); ?>
|
||||
</div>
|
||||
|
||||
<p>Falls das Scannen nicht klappt, trage das Geheimnis von Hand ein:<br>
|
||||
<span style="font-family: monospace; font-size: 1.15em; letter-spacing: 0.1em;"><?php
|
||||
echo saas_html(trim(chunk_split($setupSecret, 4, ' '))); ?></span></p>
|
||||
|
||||
<form method="post" action="zwei-faktor-einrichten.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="bestaetigen">
|
||||
<label for="code">Code aus der App</label>
|
||||
<input type="text" name="code" id="code" inputmode="numeric" autocomplete="one-time-code" required>
|
||||
<p><button type="submit" class="primary">Einrichtung abschließen</button></p>
|
||||
</form>
|
||||
|
||||
<?php else: ?>
|
||||
<p>Mit einem zweiten Faktor reicht dein Passwort allein nicht mehr aus, um sich
|
||||
anzumelden. Zusätzlich wird ein sechsstelliger Code aus einer Authenticator-App
|
||||
auf deinem Telefon verlangt.</p>
|
||||
<p>Du brauchst dafür eine App wie Aegis, 2FAS oder Google Authenticator. Die Codes
|
||||
entstehen auf deinem Gerät; die Kaffeeliste sendet dafür nichts an Dritte.</p>
|
||||
<form method="post" action="zwei-faktor-einrichten.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="starten">
|
||||
<p><button type="submit" class="primary">Einrichtung starten</button></p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<p><a href="konto.php">← Zurück zum Kundenkonto</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/rate-limit.php';
|
||||
require_once __DIR__ . '/app/totp.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$errors = [];
|
||||
|
||||
// Ohne angefangene Anmeldung hat diese Seite keinen Sinn - und darf auch
|
||||
// nichts verraten. Zurueck zum Login.
|
||||
$userId = app_totp_challenge_user_id();
|
||||
if ($userId === null) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
|
||||
// Sechs Ziffern sind schnell durchprobiert: hier ist das Limit enger als
|
||||
// beim Passwort und haengt zusaetzlich am Konto, nicht nur an der IP.
|
||||
$userBucketOk = app_rate_limit_check($pdo, '2fa_user:' . $userId, 5, 900);
|
||||
$ipBucketOk = app_rate_limit_check($pdo, '2fa_ip:' . app_client_ip(), 20, 900);
|
||||
|
||||
if (!$userBucketOk || !$ipBucketOk) {
|
||||
$errors[] = 'Zu viele Versuche. Bitte warte einige Minuten.';
|
||||
} else {
|
||||
$code = trim((string)($_POST['code'] ?? ''));
|
||||
|
||||
// Ein Wiederherstellungscode enthaelt Buchstaben, ein App-Code nie -
|
||||
// daran laesst sich beides ohne zusaetzliches Feld unterscheiden.
|
||||
$looksLikeRecoveryCode = preg_match('/[a-zA-Z]/', $code) === 1;
|
||||
|
||||
$accepted = $looksLikeRecoveryCode
|
||||
? app_totp_consume_recovery_code($pdo, $userId, $code)
|
||||
: app_totp_verify_for_user($pdo, $userId, $code);
|
||||
|
||||
if ($accepted) {
|
||||
app_totp_finish_login($pdo, $userId);
|
||||
}
|
||||
|
||||
$errors[] = 'Der Code stimmt nicht. Bitte prüfe die Uhrzeit deines Geräts und versuche es erneut.';
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Kaffeeliste Bestätigung</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/public.css" />
|
||||
</head>
|
||||
<body class="is-preload public-page">
|
||||
<div class="public-shell">
|
||||
<section class="public-auth">
|
||||
<nav class="public-nav" aria-label="Hauptnavigation">
|
||||
<strong><a href="landing.php">Kaffeeliste</a></strong>
|
||||
<ul class="actions">
|
||||
<li><a href="login.php" class="button">Abbrechen</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="public-auth-grid">
|
||||
<div class="public-auth-copy">
|
||||
<h1>Bestätigung</h1>
|
||||
<p>Dein Konto ist mit einem zweiten Faktor geschützt. Gib den sechsstelligen Code
|
||||
aus deiner Authenticator-App ein.</p>
|
||||
</div>
|
||||
|
||||
<div class="public-panel">
|
||||
<h2>Zur App</h2>
|
||||
|
||||
<?php if ($errors !== []): ?>
|
||||
<div class="hint-box error">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<p><?php echo saas_html($error); ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="zwei-faktor.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<label for="code">Code</label>
|
||||
<input type="text" name="code" id="code" inputmode="numeric"
|
||||
autocomplete="one-time-code" autofocus required
|
||||
placeholder="123456">
|
||||
</div>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit" class="primary">Bestätigen</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<p>Kein Zugriff auf die App? Gib stattdessen einen deiner
|
||||
Wiederherstellungscodes ein.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="public-footer-links">
|
||||
<a href="impressum.php">Impressum</a>
|
||||
<a href="agb.php">AGB</a>
|
||||
<a href="datenschutz.php">Datenschutz</a>
|
||||
<a href="avv.php">AVV</a>
|
||||
<a href="widerruf.php">Widerrufsbelehrung</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user