* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 * Contributors: * - Anton Afanasyeu (2 commits, 56 lines) * - Cursor Agent (project assistant) * Digest: SHA256 a6e49e8d07788ac2b4ea48f886f9a46ec6f5c1eab5b9723e1554e1fe53765efd */ declare(strict_types=1); final class Auth { public static function user(): ?array { $u = $_SESSION['user'] ?? null; if (!$u || !empty($u['companies'])) { return $u; } $uid = (int) ($u['id'] ?? 0); if ($uid <= 0) { return $u; } $stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1'); $stmt->execute([$uid]); $row = $stmt->fetch(); if (!$row) { return $u; } Database::ensureRbacSchema(); Rbac::seedMembershipsForUser($uid, (string) ($row['role'] ?? 'viewer')); $_SESSION['user'] = Rbac::buildSessionUser($row); return $_SESSION['user']; } public static function check(): void { if (!self::user()) { header('Location: ' . self::authUrl('/login')); exit; } } public static function can(string $action): bool { return Rbac::can($action); } public static function canEditTags(?int $companyId = null): bool { return Rbac::can('edit_tags', $companyId); } /** Ticket owner or company admin+ (same gate as tag edit for alpha). */ public static function canEditTicket(array $ticket, ?int $companyId = null): bool { if (self::canEditTags($companyId)) { return true; } $user = self::user(); if (!$user) { return false; } $owner = trim((string) ($ticket['owner_username'] ?? '')); return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0; } /** * @return true|'pending_2fa'|false */ public static function login(string $username, string $password): bool|string { if (AuthAttempts::isRateLimited($username)) { return false; } $pdo = Database::pdo(); AuthEmailSchema::ensure($pdo); $stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1'); $stmt->execute([$username]); $row = $stmt->fetch(); if (!$row || !password_verify($password, $row['password_hash'])) { AuthAttempts::record('login_fail', $username); return false; } $status = (string) ($row['status'] ?? 'active'); if ($status === 'pending') { return false; } if ($status === 'locked' || $status === 'disabled') { AuthAttempts::record('login_fail', $username); return false; } $uid = (int) ($row['id'] ?? 0); if ($uid > 0 && AuthFactors::hasTotp($uid)) { $_SESSION['pending_2fa_user_id'] = $uid; $_SESSION['pending_2fa_exp'] = time() + 300; AuthAttempts::record('login_ok', $username); return 'pending_2fa'; } $_SESSION['user'] = Rbac::buildSessionUser($row); AuthAttempts::record('login_ok', $username); return true; } public static function completeTotpLogin(string $code): bool { $uid = (int) ($_SESSION['pending_2fa_user_id'] ?? 0); $exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0); if ($uid <= 0 || $exp < time()) { self::clearPending2fa(); return false; } $secret = AuthFactors::getTotpSecret($uid); if ($secret === null || !AuthTotp::verify($secret, $code)) { return false; } self::clearPending2fa(); $stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1'); $stmt->execute([$uid]); $row = $stmt->fetch(); if (!$row) { return false; } $_SESSION['user'] = Rbac::buildSessionUser($row); return true; } public static function pending2faUserId(): int { $exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0); if ($exp < time()) { return 0; } return (int) ($_SESSION['pending_2fa_user_id'] ?? 0); } public static function clearPending2fa(): void { unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']); } public static function logout(): void { self::clearPending2fa(); unset($_SESSION['user']); } public static function basePath(): string { $bp = rtrim((string) cfg('base_path', ''), '/'); if ($bp === '') { return $bp; } // If nginx serves /issues/ but config.php still has legacy /crashes, asset URLs must match nginx. $uri = (string) ($_SERVER['REQUEST_URI'] ?? ''); if (str_contains($uri, '/issues') && str_ends_with($bp, '/crashes')) { return substr($bp, 0, -strlen('/crashes')) . '/issues'; } return $bp; } /** Shared project prefix, e.g. /app/androidcast_project */ public static function projectBasePath(): string { $p = trim((string) cfg('project_base_path', '')); if ($p !== '') { return rtrim($p, '/'); } $bp = self::basePath(); if (preg_match('#^(.+)/(?:crashes|issues|graphs|build)$#', $bp, $m)) { return $m[1]; } return '/app/androidcast_project'; } /** Login/logout/register/2FA live at project root, not under /issues/. */ public static function authUrl(string $path = ''): string { $root = self::projectBasePath(); $path = '/' . ltrim($path, '/'); if ($path === '/' || $path === '') { return $root; } return $root . $path; } }