1
0
mirror of git://f0xx.org/ac/ac-be-auth synced 2026-07-29 03:39:21 +03:00

feat(2fa): cross-device approval UI, polling JS, approve view

- two_factor_challenge.php: QR now shown above the code field with
  data-poll-token/url attrs; approval status banner; updated caption
- two_factor_approve.php: new mobile confirmation page (Approve/Cancel)
- two_factor.js: adds poll loop every 2.5s; auto-redirect on approval;
  expires QR visually; stops polling on manual TOTP submit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-27 22:49:50 +02:00
parent 393c0c1f29
commit d8d15d77ad
3 changed files with 183 additions and 10 deletions

View File

@@ -2,14 +2,16 @@
'use strict'; 'use strict';
function boot() { function boot() {
var form = document.querySelector('form.login-card'); var form = document.querySelector('form.login-card');
var input = document.getElementById('twofa-code-input'); var input = document.getElementById('twofa-code-input');
var script = document.currentScript || document.querySelector('script[data-poll-token]');
if (!form || !input) { if (!form || !input) {
return; return;
} }
input.focus(); input.focus();
input.select(); input.select();
// ── 6-digit TOTP input helpers ─────────────────────────────────────────
function digitsOnly(val) { function digitsOnly(val) {
return String(val || '').replace(/\D/g, '').slice(0, 6); return String(val || '').replace(/\D/g, '').slice(0, 6);
} }
@@ -56,6 +58,91 @@
input.value = digitsOnly(input.value + ev.key); input.value = digitsOnly(input.value + ev.key);
maybeSubmit(); 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') { if (document.readyState === 'loading') {

View File

@@ -0,0 +1,71 @@
<?php
$bp = Auth::basePath();
$auth = Auth::authUrl();
$user = Auth::user();
$rawToken = trim($_GET['token'] ?? $_POST['token'] ?? '');
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Approve sign-in — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
</head>
<body class="login-page">
<div class="login-card" style="text-align:center;">
<h1 style="font-size:1.5rem;margin-bottom:0.5rem;">
<?php if (!empty($approveResult)): ?>
<?= $approveResult === 'ok' ? '✅' : '⚠️' ?>
<?php else: ?>
🔐
<?php endif; ?>
</h1>
<?php if (!empty($approveResult)): ?>
<?php if ($approveResult === 'ok'): ?>
<p style="font-size:1.1rem;font-weight:600;" data-i18n="twofa.approve.success">
Sign-in approved.
</p>
<p class="muted">Your other device should now be logged in automatically.</p>
<?php else: ?>
<p class="alert">This approval link has expired or is invalid.</p>
<?php endif; ?>
<p style="margin-top:1.5rem;">
<a href="<?= h($auth) ?>/" class="button">Back to hub</a>
</p>
<?php elseif ($approvalInfo): ?>
<p style="font-size:1.1rem;font-weight:600;">Approve sign-in on another device?</p>
<p class="muted">
Tap <b>Approve</b> to log in the browser that showed you this QR code.<br>
This link expires in a few minutes.
</p>
<form method="post" action="<?= h($auth) ?>/two-factor/approve" style="margin-top:1.5rem;">
<input type="hidden" name="token" value="<?= h($rawToken) ?>">
<button type="submit" style="width:100%;font-size:1.1rem;">Approve</button>
</form>
<p style="margin-top:1rem;">
<a href="<?= h($auth) ?>/" class="muted" style="font-size:0.9rem;">Cancel</a>
</p>
<?php else: ?>
<p class="alert">This approval link has expired or has already been used.</p>
<p style="margin-top:1rem;">
<a href="<?= h($auth) ?>/" class="button">Back to hub</a>
</p>
<?php endif; ?>
</div>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -19,7 +19,12 @@ $twofaQr = AuthTwoFactorPage::qrPayload();
</script> </script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css"> <link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script> <script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h($bp) ?>/assets/js/two_factor.js" defer></script> <script src="<?= h($bp) ?>/assets/js/two_factor.js" defer
<?php if (is_array($twofaQr) && !empty($twofaQr['raw_token'])): ?>
data-poll-token="<?= h($twofaQr['raw_token']) ?>"
data-poll-url="<?= h($auth) ?>/api/two-factor/poll"
<?php endif; ?>
></script>
</head> </head>
<body class="login-page" data-base-path="<?= h($bp) ?>"> <body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale"> <div class="login-locale">
@@ -33,16 +38,26 @@ $twofaQr = AuthTwoFactorPage::qrPayload();
</div> </div>
<form class="login-card" method="post" action="<?= h($auth) ?>/two-factor" id="twofa-form"> <form class="login-card" method="post" action="<?= h($auth) ?>/two-factor" id="twofa-form">
<h1 data-i18n="twofa.title">Two-factor authentication</h1> <h1 data-i18n="twofa.title">Two-factor authentication</h1>
<p class="muted" data-i18n="twofa.hint">Enter the 6-digit code from your authenticator app.</p> <?php if (is_array($twofaQr) && !empty($twofaQr['qr_url'])): ?>
<p class="muted" data-i18n="twofa.hint_scan">Scan the QR code with your phone to approve this sign-in, or enter the 6-digit code below.</p>
<figure class="twofa-qr-wrap" id="twofa-qr-wrap">
<img src="<?= h((string) $twofaQr['qr_url']) ?>" width="160" height="160" alt="QR code — scan to approve sign-in on this device" class="twofa-qr">
<figcaption class="muted">
Scan to approve on mobile
<?php if (!empty($twofaQr['short_url'])): ?>
· <a href="<?= h((string) $twofaQr['short_url']) ?>" rel="noopener"><?= h((string) $twofaQr['short_url']) ?></a>
<?php endif; ?>
</figcaption>
</figure>
<div id="twofa-approval-status" class="muted" style="margin-bottom:0.75rem;display:none;">
⏳ Waiting for mobile approval…
</div>
<?php else: ?>
<p class="muted" data-i18n="twofa.hint">Enter the 6-digit code from your authenticator app.</p>
<?php endif; ?>
<?php if (!empty($twofaError)): ?> <?php if (!empty($twofaError)): ?>
<div class="alert" data-i18n="twofa.error"><?= h($twofaError) ?></div> <div class="alert" data-i18n="twofa.error"><?= h($twofaError) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (is_array($twofaQr) && !empty($twofaQr['qr_url'])): ?>
<figure class="twofa-qr-wrap">
<img src="<?= h((string) $twofaQr['qr_url']) ?>" width="160" height="160" alt="QR code for mobile sign-in link" class="twofa-qr">
<figcaption class="muted">Scan to open the hub on your phone<?php if (!empty($twofaQr['short_url'])): ?> · <a href="<?= h((string) $twofaQr['short_url']) ?>" rel="noopener"><?= h((string) $twofaQr['short_url']) ?></a><?php endif; ?></figcaption>
</figure>
<?php endif; ?>
<label><span data-i18n="twofa.code">Authentication code</span> <label><span data-i18n="twofa.code">Authentication code</span>
<input id="twofa-code-input" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label> <input id="twofa-code-input" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label>
<button type="submit" data-i18n="twofa.submit">Continue</button> <button type="submit" data-i18n="twofa.submit">Continue</button>