mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 03:38:07 +03:00
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -4,12 +4,14 @@
|
|||||||
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') {
|
||||||
|
|||||||
@@ -260,6 +260,62 @@ if ($route === '/two-factor') {
|
|||||||
exit;
|
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') {
|
if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
Auth::check();
|
Auth::check();
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
71
sim/cluster0/lab-seeds/backend/views/two_factor_approve.php
Normal file
71
sim/cluster0/lab-seeds/backend/views/two_factor_approve.php
Normal 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>
|
||||||
@@ -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>
|
||||||
|
<?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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user