loginByToken($token); } // Session cookie $sessionId = $_COOKIE['ncpx_session'] ?? ''; if ($sessionId) { return $this->loginBySession($sessionId); } return false; } private function loginBySession(string $sessionId): bool { $db = DB::getInstance(); $row = $db->fetchOne( "SELECT s.impersonator_id, s.expires_at, u.id as uid, u.username, u.email, u.role, u.status, u.reseller_id, u.theme FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > NOW() AND u.status = 'active'", [hash('sha256', $sessionId)] ); if (!$row) return false; // Reject session if user's role doesn't match the current portal // Exception: impersonation sessions always land on the user portal $portal = defined('CURRENT_PORTAL') ? CURRENT_PORTAL : 'user'; $allowed = match($portal) { 'admin' => ['admin'], 'reseller' => ['reseller'], default => ['user'], }; if (!in_array($row['role'], $allowed, true)) return false; $this->user = $row; return true; } private function loginByToken(string $token): bool { $db = DB::getInstance(); $row = $db->fetchOne( "SELECT t.permissions, u.id as uid, u.username, u.email, u.role, u.status FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ? AND (t.expires_at IS NULL OR t.expires_at > NOW()) AND u.status = 'active'", [hash('sha256', $token)] ); if (!$row) return false; $db->execute("UPDATE api_tokens SET last_used = NOW() WHERE token = ?", [hash('sha256', $token)]); $this->user = $row; return true; } /** * Returns null (bad credentials), self::TOTP_REQUIRED (need 2FA code), or session token string. */ public function attempt(string $username, string $password, ?string $totpCode = null): ?string { $db = DB::getInstance(); $user = $db->fetchOne( "SELECT * FROM users WHERE (username = ? OR email = ?) AND status = 'active'", [$username, $username] ); if (!$user || !password_verify($password, $user['password'])) return null; // Portal role enforcement — each panel only accepts its own role $portal = defined('CURRENT_PORTAL') ? CURRENT_PORTAL : 'user'; $allowed = match($portal) { 'admin' => ['admin'], 'reseller' => ['reseller'], default => ['user'], }; if (!in_array($user['role'], $allowed, true)) return null; // TOTP check if (!empty($user['totp_enabled'])) { if ($totpCode === null) { $this->user = $user; return self::TOTP_REQUIRED; } $verified = TOTP::verify($user['totp_secret'] ?? '', $totpCode); if (!$verified && !empty($user['totp_backup_codes'])) { $verified = TOTP::verifyBackupCode($totpCode, $user['totp_backup_codes']); if ($verified) { // Consume used backup code $hashes = json_decode($user['totp_backup_codes'], true) ?? []; $hashes = array_values(array_filter($hashes, fn($h) => !password_verify(strtoupper($totpCode), $h))); $db->execute("UPDATE users SET totp_backup_codes=? WHERE id=?", [json_encode($hashes), $user['id']]); } } if (!$verified) return null; } // Create session $token = bin2hex(random_bytes(32)); $sessionId = hash('sha256', $token); $db->execute( "INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR))", [$sessionId, $user['id'], $_SERVER['REMOTE_ADDR'] ?? '', $_SERVER['HTTP_USER_AGENT'] ?? ''] ); $db->execute("UPDATE users SET last_login = NOW() WHERE id = ?", [$user['id']]); setcookie('ncpx_session', $token, [ 'expires' => time() + 28800, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict', ]); $this->user = $user; return $token; } public function logout(): void { $sessionId = hash('sha256', $_COOKIE['ncpx_session'] ?? ''); DB::getInstance()->execute("DELETE FROM sessions WHERE id = ?", [$sessionId]); setcookie('ncpx_session', '', time() - 3600, '/', '', true, true); } public function user(): ?array { return $this->user; } public function require(string ...$roles): void { $user = $this->user(); if (!$user || !in_array($user['role'], $roles)) { Response::error('Forbidden', 403); } } /** * Returns the correct panel URL for a given role * Used by login redirect so each role lands on the right port */ public static function portalUrl(string $role, string $path = '/'): string { $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; // No port in HTTP_HOST means the request came through a reverse proxy on 443 — stay on same host if (!preg_match('/:\d+$/', $host)) { return "https://{$host}{$path}"; } // Direct access — redirect to the correct panel port $hostname = preg_replace('/:\d+$/', '', $host); $port = match($role) { 'admin' => PORT_ADMIN, 'reseller' => PORT_RESELLER, default => PORT_USER, }; return "https://{$hostname}:{$port}{$path}"; } }