mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 07:17:38 +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:
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user