From 002e9cb71007ecdf52151795fa83ec703f98d477 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 11 Jul 2026 14:48:27 +0200 Subject: [PATCH] fix(auth): 2FA redirect chain, QR approve via TOTP, 24h session Honor post-login redirect through password and TOTP steps; approve cross-device sign-in with authenticator code when mobile has no session; reuse pending QR token; fix verify-email URL; purge stale pending registrations. Co-authored-by: Cursor --- src/Auth.php | 59 ++++++++++++++++++++++++++++++++++++++- src/AuthApproval.php | 19 +++++++++++++ src/AuthRegistration.php | 24 ++++++++++++++-- src/AuthTwoFactorPage.php | 11 +++++--- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 5291ea0..3fb45e4 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -34,11 +34,65 @@ final class Auth { return $_SESSION['user']; } + private const SESSION_IDLE_SECONDS = 86400; + public static function check(): void { if (!self::user()) { - header('Location: ' . self::authUrl('/login')); + $return = (string) ($_SERVER['REQUEST_URI'] ?? ''); + $q = $return !== '' ? '?redirect=' . urlencode($return) : ''; + header('Location: ' . self::authUrl('/login') . $q); exit; } + $last = (int) ($_SESSION['last_activity'] ?? 0); + if ($last > 0 && (time() - $last) > self::SESSION_IDLE_SECONDS) { + self::logout(); + $return = (string) ($_SERVER['REQUEST_URI'] ?? ''); + $q = $return !== '' ? '?redirect=' . urlencode($return) : ''; + header('Location: ' . self::authUrl('/login') . $q); + exit; + } + self::touchSession(); + } + + public static function touchSession(): void { + if (self::user()) { + $_SESSION['last_activity'] = time(); + } + } + + /** Accept only in-project relative URLs (open redirect safe). */ + public static function safeRedirect(string $url): ?string { + $url = trim($url); + if ($url === '') { + return null; + } + $project = self::projectBasePath(); + if (str_starts_with($url, $project . '/') || $url === $project) { + return $url; + } + if (str_starts_with($url, '/') && !str_starts_with($url, '//') && str_starts_with($url, $project)) { + return $url; + } + return null; + } + + public static function captureRedirectFromRequest(): void { + $from = trim((string) ($_GET['redirect'] ?? $_POST['redirect'] ?? '')); + $safe = self::safeRedirect($from); + if ($safe !== null) { + $_SESSION['post_login_redirect'] = $safe; + } + } + + public static function peekPostLoginRedirect(string $default): string { + $redir = (string) ($_SESSION['post_login_redirect'] ?? ''); + return self::safeRedirect($redir) ?? $default; + } + + public static function postLoginRedirect(string $default): string { + $target = self::peekPostLoginRedirect($default); + unset($_SESSION['post_login_redirect']); + return $target; } public static function can(string $action): bool { @@ -94,6 +148,7 @@ final class Auth { return 'pending_2fa'; } $_SESSION['user'] = Rbac::buildSessionUser($row); + self::touchSession(); AuthAttempts::record('login_ok', $username); return true; } @@ -117,6 +172,7 @@ final class Auth { return false; } $_SESSION['user'] = Rbac::buildSessionUser($row); + self::touchSession(); return true; } @@ -154,6 +210,7 @@ final class Auth { return false; } $_SESSION['user'] = Rbac::buildSessionUser($row); + self::touchSession(); return true; } diff --git a/src/AuthApproval.php b/src/AuthApproval.php index 5fd041e..27c6fee 100644 --- a/src/AuthApproval.php +++ b/src/AuthApproval.php @@ -130,6 +130,25 @@ final class AuthApproval { return (int) $row['user_id']; } + /** + * Approve using TOTP (mobile not logged in — scan QR, enter authenticator code). + */ + public static function approveWithTotp(string $rawToken, string $code, string $approverIp): bool { + $info = self::getForToken($rawToken); + if ($info === null) { + return false; + } + $uid = (int) ($info['user_id'] ?? 0); + if ($uid <= 0) { + return false; + } + $secret = AuthFactors::getTotpSecret($uid); + if ($secret === null || !AuthTotp::verify($secret, $code)) { + return false; + } + return self::approve($rawToken, $uid, $approverIp); + } + /** Lookup the user_id and desktop_session_id for a token (for the approve confirmation page). */ public static function getForToken(string $rawToken): ?array { $tokenHash = hash('sha256', $rawToken); diff --git a/src/AuthRegistration.php b/src/AuthRegistration.php index 8f5e311..7a0a33f 100644 --- a/src/AuthRegistration.php +++ b/src/AuthRegistration.php @@ -32,6 +32,7 @@ final class AuthRegistration { $username = strstr($emailNorm, '@', true) ?: $emailNorm; } $pdo = Database::pdo(); + self::purgeStalePending($pdo); $st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); $st->execute([$emailNorm, $username]); if ($st->fetchColumn()) { @@ -56,8 +57,8 @@ final class AuthRegistration { 'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)' )->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]); - $base = rtrim((string) cfg('base_path', ''), '/'); - $verifyUrl = 'https://apps.f0xx.org' . $base . '/verify-email?token=' . urlencode($token); + $origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/'); + $verifyUrl = $origin . Auth::authUrl('/verify-email') . '?token=' . urlencode($token); if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) { error_log('AuthRegistration: verify mail failed for ' . $emailNorm); } @@ -98,4 +99,23 @@ final class AuthRegistration { private static function normalizeEmail(string $email): string { return strtolower(trim($email)); } + + /** Drop unverified accounts started more than 24h ago. */ + private static function purgeStalePending(\PDO $pdo): void { + $cutoff = date('Y-m-d H:i:s', time() - self::TOKEN_TTL_S); + $st = $pdo->prepare( + "SELECT id FROM users WHERE status = 'pending' AND email_verified_at IS NULL + AND (created_at IS NULL OR created_at < ?)" + ); + $st->execute([$cutoff]); + $ids = $st->fetchAll(\PDO::FETCH_COLUMN); + foreach ($ids as $id) { + $uid = (int) $id; + if ($uid <= 0) { + continue; + } + $pdo->prepare('DELETE FROM auth_tokens WHERE user_id = ?')->execute([$uid]); + $pdo->prepare('DELETE FROM users WHERE id = ? AND status = ?')->execute([$uid, 'pending']); + } + } } diff --git a/src/AuthTwoFactorPage.php b/src/AuthTwoFactorPage.php index d8a5d62..35950c0 100644 --- a/src/AuthTwoFactorPage.php +++ b/src/AuthTwoFactorPage.php @@ -21,10 +21,13 @@ final class AuthTwoFactorPage { } $project = Auth::projectBasePath(); $ip = Auth::clientIp(); - $rawToken = AuthApproval::create($uid, session_id(), $ip); - - // Store token in session so the poll endpoint can reference it - $_SESSION['pending_2fa_approval_token'] = $rawToken; + $existing = (string) ($_SESSION['pending_2fa_approval_token'] ?? ''); + if ($existing !== '' && AuthApproval::pollStatus($existing) === 'pending') { + $rawToken = $existing; + } else { + $rawToken = AuthApproval::create($uid, session_id(), $ip); + $_SESSION['pending_2fa_approval_token'] = $rawToken; + } $origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/'); $approveUrl = $origin . $project . '/two-factor/approve?token=' . urlencode($rawToken);