mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
135bbcb0b3
- WordPressManager.php: wp-cli wrapper for install/update/clone/delete - BackupManager.php: tar+mysqldump, schedules, retention, rclone - CloudflareManager.php: zone/record management, sync, cache purge - TOTP.php: RFC 6238 pure-PHP with backup codes - Auth.php: TOTP_REQUIRED two-step login flow - 4 new API endpoints: wordpress, backup, cloudflare, totp - DB migration 002: TOTP cols, CF cols, wordpress_installs, backups tables - admin.js: full UI for all 4 features + TOTP login step - admin/index.php: sidebar nav for WordPress, 2FA Manager, Cloudflare Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
5.3 KiB
PHP
143 lines
5.3 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.*, 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;
|
|
$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;
|
|
|
|
// 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';
|
|
$hostname = preg_replace('/:\d+$/', '', $host);
|
|
$port = match($role) {
|
|
'admin' => PORT_ADMIN,
|
|
'reseller' => PORT_RESELLER,
|
|
default => PORT_USER,
|
|
};
|
|
return "https://{$hostname}:{$port}{$path}";
|
|
}
|
|
}
|