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;
|
||||
}
|
||||
Reference in New Issue
Block a user