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:
59
src/Auth.php
59
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user