Files
novacpx/panel/lib/Auth.php
T
myron 956defc34b fix: all code review security findings
- CORS: replace open regex with explicit hostname allowlist + port whitelist
- Exception handler: only expose RuntimeException/InvalidArgumentException
  messages; PDOException and others return generic 'internal error'
- Auth::portalUrl(): allowlist-validate HTTP_HOST before using it in
  redirect URL — prevents open redirect via Host header injection
- _branding.php custom_css: strip HTML tags, js: URLs, @import, expression()
  instead of just </style> which was trivially bypassable
- accounts create: check accounts table as well as users for username
  uniqueness (TOCTOU fix); wrap user INSERT + provisioning in single
  transaction so rollback is atomic on failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
2026-06-21 16:03:26 +00:00

185 lines
7.0 KiB
PHP

<?php
if (!class_exists('TOTP')) require_once __DIR__ . '/TOTP.php';
class Auth {
private static ?Auth $instance = null;
private ?array $user = null;
// Returned by attempt() when password is correct but TOTP code still needed
public const TOTP_REQUIRED = 'TOTP_REQUIRED';
private function __construct() {}
public static function getInstance(): self {
if (!self::$instance) self::$instance = new self();
return self::$instance;
}
public function check(): bool {
// Bearer token (API)
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (str_starts_with($header, 'Bearer ')) {
$token = substr($header, 7);
return $this->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'] ?? '';
// Allowlist of trusted hostnames — prevents open redirect via Host header injection
$allowed = [
'novacpx.orbishosting.com',
'admin.novacpx.orbishosting.com',
'reseller.novacpx.orbishosting.com',
'panel.novacpx.orbishosting.com',
'web.orbishosting.com',
];
$hostname = preg_replace('/:\d+$/', '', $host);
// Trusted proxy (no port) — stay on same host
if ($host && !preg_match('/:\d+$/', $host) && in_array($hostname, $allowed, true)) {
return "https://{$hostname}{$path}";
}
// Direct port access — validate hostname is a known server IP or allowed host
$port = match($role) {
'admin' => PORT_ADMIN,
'reseller' => PORT_RESELLER,
default => PORT_USER,
};
// Only redirect to localhost/LAN IPs on direct panel access
if ($hostname && (filter_var($hostname, FILTER_VALIDATE_IP) || in_array($hostname, $allowed, true))) {
return "https://{$hostname}:{$port}{$path}";
}
// Fallback — safe relative path with no host (works for same-origin redirects)
return $path;
}
}