1
0
mirror of git://f0xx.org/ac/ac-ms-identity synced 2026-07-30 02:37:39 +03:00

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 <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-07-11 14:48:27 +02:00
parent 48459cc392
commit 002e9cb710
4 changed files with 106 additions and 7 deletions

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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']);
}
}
}

View File

@@ -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);