From 3c5233c90334b752ea0bb9c57ffb3215ed510326 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 27 Jun 2026 22:49:59 +0200 Subject: [PATCH] feat(2fa): add cross-device approval routes to composed backend router - New routes: GET/POST /two-factor/approve, GET /api/two-factor/poll - Nginx regex extended: (login|...|api/two-factor)(/|$) - lab-seeds views/JS synced from ac-be-auth Co-authored-by: Cursor --- .../backend/public/assets/js/two_factor.js | 91 ++++++++++++++++++- .../lab-seeds/backend/public/index.php | 56 ++++++++++++ .../backend/views/two_factor_approve.php | 71 +++++++++++++++ .../backend/views/two_factor_challenge.php | 31 +++++-- 4 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 sim/cluster0/lab-seeds/backend/views/two_factor_approve.php diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/two_factor.js b/sim/cluster0/lab-seeds/backend/public/assets/js/two_factor.js index 3e5ec56..408a26a 100644 --- a/sim/cluster0/lab-seeds/backend/public/assets/js/two_factor.js +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/two_factor.js @@ -2,14 +2,16 @@ 'use strict'; function boot() { - var form = document.querySelector('form.login-card'); - var input = document.getElementById('twofa-code-input'); + var form = document.querySelector('form.login-card'); + var input = document.getElementById('twofa-code-input'); + var script = document.currentScript || document.querySelector('script[data-poll-token]'); if (!form || !input) { return; } input.focus(); input.select(); + // ── 6-digit TOTP input helpers ───────────────────────────────────────── function digitsOnly(val) { return String(val || '').replace(/\D/g, '').slice(0, 6); } @@ -56,6 +58,91 @@ input.value = digitsOnly(input.value + ev.key); maybeSubmit(); }); + + // ── Cross-device approval polling ────────────────────────────────────── + var pollToken = script && script.getAttribute('data-poll-token'); + var pollUrl = script && script.getAttribute('data-poll-url'); + if (!pollToken || !pollUrl) { + return; + } + + var statusEl = document.getElementById('twofa-approval-status'); + var qrWrap = document.getElementById('twofa-qr-wrap'); + var pollTimer = null; + var pollActive = true; + var INTERVAL = 2500; // ms + + function showStatus(msg) { + if (statusEl) { + statusEl.style.display = ''; + statusEl.textContent = msg; + } + } + + function stopPolling() { + pollActive = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + } + + function poll() { + if (!pollActive) { + return; + } + var url = pollUrl + '?token=' + encodeURIComponent(pollToken); + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.onload = function () { + if (!pollActive) { + return; + } + try { + var data = JSON.parse(xhr.responseText); + } catch (e) { + scheduleNext(); + return; + } + if (data.status === 'approved' && data.redirect) { + stopPolling(); + showStatus('✅ Approved — signing you in…'); + setTimeout(function () { + window.location.href = data.redirect; + }, 400); + return; + } + if (data.status === 'expired' || data.status === 'used' || data.status === 'not_found') { + stopPolling(); + showStatus('⚠️ QR code expired. Reload to get a new one.'); + if (qrWrap) { + qrWrap.style.opacity = '0.35'; + } + return; + } + // status === 'pending' — keep polling + showStatus('⏳ Waiting for mobile approval…'); + scheduleNext(); + }; + xhr.onerror = function () { + scheduleNext(); + }; + xhr.send(); + } + + function scheduleNext() { + if (pollActive) { + pollTimer = setTimeout(poll, INTERVAL); + } + } + + // Start polling after a short delay so the page renders first + pollTimer = setTimeout(poll, 1000); + + // Stop polling if the user submits the TOTP form manually + form.addEventListener('submit', function () { + stopPolling(); + }); } if (document.readyState === 'loading') { diff --git a/sim/cluster0/lab-seeds/backend/public/index.php b/sim/cluster0/lab-seeds/backend/public/index.php index 2f5cdfa..994ce9f 100644 --- a/sim/cluster0/lab-seeds/backend/public/index.php +++ b/sim/cluster0/lab-seeds/backend/public/index.php @@ -260,6 +260,62 @@ if ($route === '/two-factor') { exit; } +// ── Cross-device 2FA approval (mobile scans QR, approves desktop session) ── + +// Poll endpoint — desktop JS calls this every 2.5 s to check approval status. +// When approved, also completes the server-side session. +if ( + ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET' && + ($route === '/api/two-factor/poll' || str_ends_with($route, '/api/two-factor/poll')) +) { + header('Content-Type: application/json'); + $rawToken = trim($_GET['token'] ?? ''); + if ($rawToken === '' || Auth::pending2faUserId() <= 0) { + echo json_encode(['status' => 'expired']); + exit; + } + $status = AuthApproval::pollStatus($rawToken); + if ($status === 'approved') { + // Attempt to complete the login server-side within this same request + if (Auth::completeTotpLoginByApproval($rawToken)) { + echo json_encode(['status' => 'approved', 'redirect' => $base . '/']); + } else { + echo json_encode(['status' => 'expired']); + } + exit; + } + echo json_encode(['status' => $status]); + exit; +} + +// Approval confirmation page shown to the already-logged-in mobile user. +if ($route === '/two-factor/approve' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $mobileUser = Auth::user(); + if (!$mobileUser) { + header('Location: ' . Auth::authUrl('/login') . '?redirect=' . urlencode(Auth::authUrl('/two-factor/approve') . '?token=' . urlencode($_POST['token'] ?? ''))); + exit; + } + $rawToken = trim($_POST['token'] ?? ''); + $approved = $rawToken !== '' && AuthApproval::approve($rawToken, (int) ($mobileUser['id'] ?? 0), Auth::clientIp()); + $approveResult = $approved ? 'ok' : 'error'; + require __DIR__ . '/../views/two_factor_approve.php'; + exit; +} + +if ($route === '/two-factor/approve') { + $rawToken = trim($_GET['token'] ?? ''); + $mobileUser = Auth::user(); + if (!$mobileUser) { + // Not logged in on this device — send to login, come back here after + $returnUrl = Auth::authUrl('/two-factor/approve') . '?token=' . urlencode($rawToken); + header('Location: ' . Auth::authUrl('/login') . '?redirect=' . urlencode($returnUrl)); + exit; + } + $approvalInfo = $rawToken !== '' ? AuthApproval::getForToken($rawToken) : null; + require __DIR__ . '/../views/two_factor_approve.php'; + exit; +} + if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') { Auth::check(); $user = Auth::user(); diff --git a/sim/cluster0/lab-seeds/backend/views/two_factor_approve.php b/sim/cluster0/lab-seeds/backend/views/two_factor_approve.php new file mode 100644 index 0000000..2471c9d --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/views/two_factor_approve.php @@ -0,0 +1,71 @@ + + + + + + + Approve sign-in — <?= h(cfg('app_name')) ?> + + + + + + + + diff --git a/sim/cluster0/lab-seeds/backend/views/two_factor_challenge.php b/sim/cluster0/lab-seeds/backend/views/two_factor_challenge.php index e3a96ea..e81344f 100644 --- a/sim/cluster0/lab-seeds/backend/views/two_factor_challenge.php +++ b/sim/cluster0/lab-seeds/backend/views/two_factor_challenge.php @@ -19,7 +19,12 @@ $twofaQr = AuthTwoFactorPage::qrPayload(); - +