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
+10
View File
@@ -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;
}
+1
View File
@@ -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',
+24
View File
@@ -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
View File
@@ -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;
+43
View File
@@ -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 1020
+5
View File
@@ -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');
+6
View File
@@ -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'; ?>
&ndash; <a href="zwei-faktor-einrichten.php">verwalten</a></td>
</tr>
</table>
<?php if ($user['email_verified_at'] === null): ?>
+15
View File
@@ -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');
+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";
+146
View File
@@ -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
? ' &ndash; 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 &ndash; 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">&larr; Zurück zum Kundenkonto</a></p>
</div>
</section>
<?php include 'footer.php'; ?>
+116
View File
@@ -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>