diff --git a/src/Auth.php b/src/Auth.php index f7db881..5291ea0 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -129,7 +129,45 @@ final class Auth { } public static function clearPending2fa(): void { - unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']); + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp'], $_SESSION['pending_2fa_approval_token']); + } + + /** + * Complete login via a cross-device approval token (no TOTP code required). + * The approval token must have been approved by the authenticated mobile user. + */ + public static function completeTotpLoginByApproval(string $rawToken): bool { + $uid = self::pending2faUserId(); + if ($uid <= 0) { + return false; + } + $sessionId = session_id(); + $consumedUid = AuthApproval::consume($rawToken, $sessionId); + if ($consumedUid <= 0 || $consumedUid !== $uid) { + return false; + } + self::clearPending2fa(); + $stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1'); + $stmt->execute([$consumedUid]); + $row = $stmt->fetch(); + if (!$row) { + return false; + } + $_SESSION['user'] = Rbac::buildSessionUser($row); + return true; + } + + /** Best-effort client IP (respects X-Forwarded-For from trusted proxy). */ + public static function clientIp(): string { + $fwd = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''); + if ($fwd !== '') { + // Take the first (original client) IP from the chain + $first = trim(explode(',', $fwd)[0]); + if (filter_var($first, FILTER_VALIDATE_IP)) { + return $first; + } + } + return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); } public static function logout(): void { diff --git a/src/AuthApproval.php b/src/AuthApproval.php new file mode 100644 index 0000000..5fd041e --- /dev/null +++ b/src/AuthApproval.php @@ -0,0 +1,150 @@ +/two-factor/approve?token= + * 3. Mobile (already logged in) opens that URL, sees a confirm page, + * POSTs to /two-factor/approve → AuthApproval::approve() sets status=approved. + * 4. Desktop polls /api/two-factor/poll?token= every ~2.5 s. + * When status=approved, Auth::completeTotpLoginByApproval() is called, + * which validates the approval and completes the session. + * + * IP hashes are SHA-256(IP) — anonymous geolocation audit trail. + */ +final class AuthApproval { + private const TTL_SECONDS = 300; + + private function __construct() { + } + + /** Mint a new approval token tied to the current desktop session and pending user. */ + public static function create(int $userId, string $desktopSessionId, string $requesterIp): string { + AuthEmailSchema::ensure(Database::pdo()); + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $ipHash = hash('sha256', $requesterIp); + $expiresAt = date('Y-m-d H:i:s', time() + self::TTL_SECONDS); + $pdo = Database::pdo(); + // Clean up expired tokens for this session first + $pdo->prepare( + "DELETE FROM auth_approvals WHERE desktop_session_id = ? AND expires_at < NOW()" + )->execute([$desktopSessionId]); + $pdo->prepare( + "INSERT INTO auth_approvals + (token_hash, desktop_session_id, user_id, status, requester_ip_hash, expires_at) + VALUES (?, ?, ?, 'pending', ?, ?)" + )->execute([$tokenHash, $desktopSessionId, $userId, $ipHash, $expiresAt]); + return $rawToken; + } + + /** + * Approve a token (called by the mobile user on POST /two-factor/approve). + * Returns true on success, false if token not found / expired / wrong user. + */ + public static function approve(string $rawToken, int $approvingUserId, string $approverIp): bool { + $tokenHash = hash('sha256', $rawToken); + $ipHash = hash('sha256', $approverIp); + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT id, user_id, status, expires_at FROM auth_approvals WHERE token_hash = ? LIMIT 1" + ); + $st->execute([$tokenHash]); + $row = $st->fetch(); + if (!$row) { + return false; + } + if ($row['status'] !== 'pending') { + return false; + } + if (strtotime((string) $row['expires_at']) < time()) { + $pdo->prepare("UPDATE auth_approvals SET status='expired' WHERE id=?")->execute([$row['id']]); + return false; + } + // The approving user must be the same user waiting on desktop + if ((int) $row['user_id'] !== $approvingUserId) { + return false; + } + $pdo->prepare( + "UPDATE auth_approvals SET status='approved', approver_ip_hash=?, approved_at=NOW() WHERE id=?" + )->execute([$ipHash, $row['id']]); + return true; + } + + /** + * Poll status for a raw token. + * Returns 'pending'|'approved'|'expired'|'used'|'not_found'. + * Also marks expired rows in the DB. + */ + public static function pollStatus(string $rawToken): string { + $tokenHash = hash('sha256', $rawToken); + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT id, status, expires_at, user_id, desktop_session_id + FROM auth_approvals WHERE token_hash = ? LIMIT 1" + ); + $st->execute([$tokenHash]); + $row = $st->fetch(); + if (!$row) { + return 'not_found'; + } + if ($row['status'] === 'pending' && strtotime((string) $row['expires_at']) < time()) { + $pdo->prepare("UPDATE auth_approvals SET status='expired' WHERE id=?")->execute([$row['id']]); + return 'expired'; + } + return (string) $row['status']; + } + + /** + * Consume an approved token and return the user_id. + * Returns 0 if the token is not in 'approved' state or doesn't match the session. + */ + public static function consume(string $rawToken, string $desktopSessionId): int { + $tokenHash = hash('sha256', $rawToken); + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT id, user_id, status, desktop_session_id, expires_at + FROM auth_approvals WHERE token_hash = ? LIMIT 1" + ); + $st->execute([$tokenHash]); + $row = $st->fetch(); + if (!$row) { + return 0; + } + if ($row['status'] !== 'approved') { + return 0; + } + if ($row['desktop_session_id'] !== $desktopSessionId) { + return 0; + } + if (strtotime((string) $row['expires_at']) < time()) { + return 0; + } + $pdo->prepare( + "UPDATE auth_approvals SET status='used', used_at=NOW() WHERE id=?" + )->execute([$row['id']]); + return (int) $row['user_id']; + } + + /** 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); + $st = Database::pdo()->prepare( + "SELECT user_id, desktop_session_id, status, expires_at + FROM auth_approvals WHERE token_hash = ? LIMIT 1" + ); + $st->execute([$tokenHash]); + $row = $st->fetch(); + if (!$row) { + return null; + } + if ($row['status'] !== 'pending' || strtotime((string) $row['expires_at']) < time()) { + return null; + } + return $row; + } +} diff --git a/src/AuthEmailSchema.php b/src/AuthEmailSchema.php index c952253..1e5c2a5 100644 --- a/src/AuthEmailSchema.php +++ b/src/AuthEmailSchema.php @@ -21,6 +21,7 @@ final class AuthEmailSchema { self::ensureAuthTokens($pdo); self::ensureAuthFactors($pdo); self::ensureAuthAttempts($pdo); + self::ensureAuthApprovals($pdo); }); self::$ensured = true; } @@ -136,5 +137,48 @@ final class AuthEmailSchema { created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_auth_attempts_ip ON auth_attempts (ip_hash, created_at)'); + } + + private static function ensureAuthApprovals(PDO $pdo): void { + if (Database::tableExists($pdo, 'auth_approvals')) { + return; + } + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE auth_approvals ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + token_hash CHAR(64) NOT NULL UNIQUE, + desktop_session_id VARCHAR(128) NOT NULL, + user_id INT UNSIGNED NOT NULL, + status ENUM('pending','approved','used','expired') NOT NULL DEFAULT 'pending', + requester_ip_hash CHAR(64) NULL, + approver_ip_hash CHAR(64) NULL, + expires_at TIMESTAMP NOT NULL, + approved_at TIMESTAMP NULL, + used_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_auth_approvals_token (token_hash), + KEY idx_auth_approvals_session (desktop_session_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + return; + } + $pdo->exec( + "CREATE TABLE auth_approvals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + desktop_session_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + requester_ip_hash TEXT NULL, + approver_ip_hash TEXT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT NULL, + used_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_approvals_token ON auth_approvals (token_hash)'); } } diff --git a/src/AuthTwoFactorPage.php b/src/AuthTwoFactorPage.php index 31ecbd6..05ac044 100644 --- a/src/AuthTwoFactorPage.php +++ b/src/AuthTwoFactorPage.php @@ -1,35 +1,56 @@ $longUrl]; + return ['long_url' => $approveUrl, 'raw_token' => $rawToken]; } $bearers = ShortLinksRepository::listBearers(); if ($bearers === []) { - return ['long_url' => $longUrl]; + return ['long_url' => $approveUrl, 'raw_token' => $rawToken]; } $bearerId = (int) ($bearers[0]['id'] ?? 0); if ($bearerId <= 0) { - return ['long_url' => $longUrl]; + return ['long_url' => $approveUrl, 'raw_token' => $rawToken]; } - $mint = ShortLinksRepository::createLink($bearerId, $longUrl, 900); + $mint = ShortLinksRepository::createLink($bearerId, $approveUrl, 900); if (empty($mint['ok'])) { - return ['long_url' => $longUrl]; + return ['long_url' => $approveUrl, 'raw_token' => $rawToken]; } + $shortUrl = (string) ($mint['short_url'] ?? ''); + // QR image encodes the short URL with ?src=qr so mobile scans are tracked + $qrUrl = $shortUrl . (str_contains($shortUrl, '?') ? '&src=qr' : '?src=qr'); return [ - 'long_url' => $longUrl, - 'short_url' => (string) ($mint['short_url'] ?? ''), - 'qr_url' => (string) ($mint['qr_url'] ?? ''), + 'long_url' => $approveUrl, + 'short_url' => $shortUrl, + 'qr_url' => $qrUrl, + 'raw_token' => $rawToken, ]; } }