BastionGuard/webui/includes/users.php

279 lines
14 KiB
PHP

<?php
require_once __DIR__ . '/functions.php';
function bg_auth_config_path() {
$primary = __DIR__ . '/webui_config.php';
if (is_file($primary)) return $primary;
// Compatibility with older installs where setup wrote config.php in the web root.
$legacy = dirname(__DIR__) . '/config.php';
if (is_file($legacy)) {
$head = @file_get_contents($legacy, false, null, 0, 4096);
if (is_string($head) && strpos($head, 'AUTH_DRIVER') !== false && strpos($head, 'BastionGuard configurations') === false) return $legacy;
}
return '';
}
function bg_config_exists() { return bg_auth_config_path() !== ''; }
function bg_load_config() { $p = bg_auth_config_path(); if ($p !== '') require_once $p; }
function bg_user_pdo() {
bg_load_config();
$driver = defined('AUTH_DRIVER') ? strtolower((string)AUTH_DRIVER) : 'mysql';
if ($driver === 'sqlite') {
$db = defined('SQLITE_DB') ? (string)SQLITE_DB : bg_data_dir() . '/users.db';
// Migrate configurations from releases that exposed the credential
// database inside the public web document root.
$legacyDb = dirname(__DIR__) . '/db/users.db';
if (str_replace('\\', '/', $db) === $legacyDb) {
$privateDb = bg_data_dir() . '/users.db';
if (is_file($legacyDb)) {
if (is_file($legacyDb . '-wal') || is_file($legacyDb . '-shm')) {
throw new RuntimeException('Legacy SQLite WAL state detected. Stop the WebUI and migrate using SQLite backup/checkpoint tools.');
}
if (is_file($privateDb)) {
throw new RuntimeException('Both legacy and private user databases exist; remove the public copy after verifying the migration.');
}
if (!@rename($legacyDb, $privateDb)) {
throw new RuntimeException('Cannot migrate public user database to a private directory.');
}
@chmod($privateDb, 0600);
}
$db = $privateDb;
}
if (dirname($db) !== '/var/lib/bastionguard-webui') {
throw new RuntimeException('SQLite authentication database must use the private persistent /var/lib/bastionguard-webui directory.');
}
if (!is_dir(dirname($db))) bg_safe_mkdir(dirname($db), 0700);
return new PDO('sqlite:' . $db, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
}
if (in_array($driver, ['pgsql','postgres','postgresql'], true)) {
$dsn = 'pgsql:host=' . DB_HOST;
if (defined('DB_PORT') && DB_PORT !== '') $dsn .= ';port=' . DB_PORT;
$dsn .= ';dbname=' . DB_NAME;
$pdo = new PDO($dsn, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
try { $pdo->exec("SET client_encoding TO 'UTF8'"); } catch (Throwable $e) { /* optional */ }
return $pdo;
}
$dsn = 'mysql:host=' . DB_HOST;
if (defined('DB_PORT') && DB_PORT !== '') $dsn .= ';port=' . DB_PORT;
$dsn .= ';dbname=' . DB_NAME . ';charset=utf8mb4';
return new PDO($dsn, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
}
function bg_users_schema(PDO $pdo) {
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$pdo->exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', created_at TEXT DEFAULT CURRENT_TIMESTAMP)");
$cols = [];
foreach ($pdo->query('PRAGMA table_info(users)') as $row) $cols[$row['name']] = true;
if (empty($cols['email'])) $pdo->exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
if (empty($cols['role'])) $pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
} elseif ($driver === 'pgsql') {
$pdo->exec("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, username VARCHAR(100) UNIQUE NOT NULL, email VARCHAR(190) NOT NULL DEFAULT '', password_hash VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'admin', created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP)");
try { $pdo->exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(190) NOT NULL DEFAULT ''"); } catch (Throwable $e) { /* already present */ }
try { $pdo->exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'admin'"); } catch (Throwable $e) { /* already present */ }
} else {
$pdo->exec("CREATE TABLE IF NOT EXISTS users (id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(100) UNIQUE NOT NULL, email VARCHAR(190) NOT NULL DEFAULT '', password_hash VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'admin', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
try { $pdo->exec("ALTER TABLE users ADD COLUMN email VARCHAR(190) NOT NULL DEFAULT ''"); } catch (Throwable $e) { /* already present */ }
try { $pdo->exec("ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'admin'"); } catch (Throwable $e) { /* already present */ }
}
}
function bg_normalize_role($role) {
$role = strtolower(trim((string)$role));
return in_array($role, ['admin','user'], true) ? $role : 'user';
}
function bg_create_user($username, $password, $role = 'user', $email = '') {
$username = trim((string)$username);
if ($username === '') throw new InvalidArgumentException('Username is required.');
if (strlen((string)$password) < 8) throw new InvalidArgumentException('Password must be at least 8 characters.');
$role = bg_normalize_role($role);
$email = trim((string)$email);
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Invalid email address.');
$pdo = bg_user_pdo();
bg_users_schema($pdo);
$stmt = $pdo->prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)');
return $stmt->execute([$username, $email, password_hash($password, PASSWORD_DEFAULT), $role]);
}
function bg_verify_user($username, $password) {
$pdo = bg_user_pdo();
bg_users_schema($pdo);
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ?');
$stmt->execute([trim((string)$username)]);
$user = $stmt->fetch();
if (!$user) return false;
$ok = false;
if (!empty($user['password_hash']) && password_verify($password, $user['password_hash'])) $ok = true;
if (!$ok && !empty($user['password_sha256']) && !empty($user['salt'])) {
$hash = hash('sha256', $user['salt'] . $password);
$ok = hash_equals($user['password_sha256'], $hash);
}
if (!$ok) return false;
if (empty($user['role'])) $user['role'] = 'admin'; // compatibility with pre-role databases
$user['role'] = bg_normalize_role($user['role']);
return $user;
}
/**
* Per-client rate limiter. REMOTE_ADDR is intentionally used rather than an
* untrusted X-Forwarded-For header. flock serializes concurrent attempts.
*/
function bg_login_rate_limit($result = null) {
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'cli');
$file = bg_data_file('login-failures-' . hash('sha256', $ip) . '.json');
$handle = @fopen($file, 'c+');
if (!$handle) return false; // fail closed if abuse protection is unavailable
if (!flock($handle, LOCK_EX)) { fclose($handle); return false; }
rewind($handle);
$state = json_decode((string)stream_get_contents($handle), true);
$now = time();
if (!is_array($state) || !isset($state['at'], $state['failures']) ||
!is_numeric($state['at']) || !is_numeric($state['failures']) ||
$now < (int)$state['at'] || $now - (int)$state['at'] >= 900) {
$state = ['at' => $now, 'failures' => 0];
}
$allowed = (int)$state['failures'] < 10;
if ($result === true) {
$state = ['at' => $now, 'failures' => 0];
} elseif ($result === false) {
$state['failures'] = min(10, (int)$state['failures'] + 1);
}
if ($result !== null) {
rewind($handle);
ftruncate($handle, 0);
fwrite($handle, json_encode($state));
fflush($handle);
}
flock($handle, LOCK_UN);
fclose($handle);
return $allowed;
}
function bg_auth_session_fingerprint(array $user) {
$credential = (string)($user['password_hash'] ?? '');
if ($credential === '') $credential = (string)($user['password_sha256'] ?? '');
return hash('sha256', $credential);
}
function bg_validate_user_session() {
if (empty($_SESSION['user']) || empty($_SESSION['user_id']) ||
!isset($_SESSION['credential_fingerprint']) || !is_string($_SESSION['credential_fingerprint'])) return false;
try {
$pdo = bg_user_pdo();
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([(int)$_SESSION['user_id']]);
$user = $stmt->fetch();
if (!$user || !hash_equals(bg_auth_session_fingerprint($user), $_SESSION['credential_fingerprint'])) return false;
// Derive authorization from the database on every request, not from
// stale privilege flags stored in a cookie-backed session.
$_SESSION['user'] = (string)$user['username'];
$_SESSION['role'] = bg_normalize_role($user['role'] ?? 'user');
return true;
} catch (Throwable $e) {
return false; // fail closed on database failure
}
}
function bg_current_user() {
return isset($_SESSION['user']) ? (string)$_SESSION['user'] : '';
}
function bg_current_user_role() {
$role = isset($_SESSION['role']) ? (string)$_SESSION['role'] : '';
return bg_normalize_role($role ?: 'user');
}
function bg_current_user_is_admin() {
return bg_current_user() !== '' && bg_current_user_role() === 'admin';
}
function bg_require_admin() {
if (bg_current_user_is_admin()) return;
if (!headers_sent()) http_response_code(403);
include __DIR__ . '/header.php';
include __DIR__ . '/sidebar.php';
echo '<div class="page-title"><div><h1>' . bg_e(bg_t('admin_required')) . '</h1><p>' . bg_e(bg_t('admin_required_desc')) . '</p></div></div>';
echo '<div class="alert alert-warning">' . bg_e(bg_t('admin_required_full')) . '</div>';
include __DIR__ . '/footer.php';
exit;
}
function bg_users_all() {
$pdo = bg_user_pdo();
bg_users_schema($pdo);
$stmt = $pdo->query('SELECT id, username, email, role, created_at FROM users ORDER BY username ASC');
$rows = $stmt ? $stmt->fetchAll() : [];
foreach ($rows as &$r) $r['role'] = bg_normalize_role($r['role'] ?? 'user');
return $rows;
}
function bg_user_by_id($id) {
$pdo = bg_user_pdo();
bg_users_schema($pdo);
$stmt = $pdo->prepare('SELECT id, username, email, role, created_at FROM users WHERE id = ?');
$stmt->execute([(int)$id]);
$u = $stmt->fetch();
if ($u) $u['role'] = bg_normalize_role($u['role'] ?? 'user');
return $u;
}
function bg_admin_count(PDO $pdo = null) {
$pdo = $pdo ?: bg_user_pdo();
bg_users_schema($pdo);
$stmt = $pdo->query("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
$row = $stmt ? $stmt->fetch() : ['c'=>0];
return (int)($row['c'] ?? 0);
}
function bg_update_user($id, $username, $role, $password = '', $email = '') {
$id = (int)$id;
$username = trim((string)$username);
if ($id <= 0 || $username === '') throw new InvalidArgumentException('Invalid user.');
$role = bg_normalize_role($role);
$email = trim((string)$email);
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Invalid email address.');
$pdo = bg_user_pdo();
bg_users_schema($pdo);
$old = bg_user_by_id($id);
if (!$old) throw new RuntimeException('User not found.');
if (($old['role'] ?? '') === 'admin' && $role !== 'admin' && bg_admin_count($pdo) <= 1) throw new RuntimeException('At least one administrator must remain.');
if ($password !== '') {
if (strlen((string)$password) < 8) throw new InvalidArgumentException('Password must be at least 8 characters.');
$stmt = $pdo->prepare('UPDATE users SET username = ?, email = ?, role = ?, password_hash = ? WHERE id = ?');
return $stmt->execute([$username, $email, $role, password_hash($password, PASSWORD_DEFAULT), $id]);
}
$stmt = $pdo->prepare('UPDATE users SET username = ?, email = ?, role = ? WHERE id = ?');
return $stmt->execute([$username, $email, $role, $id]);
}
function bg_delete_user($id) {
$id = (int)$id;
if ($id <= 0) throw new InvalidArgumentException('Invalid user.');
$pdo = bg_user_pdo();
bg_users_schema($pdo);
$old = bg_user_by_id($id);
if (!$old) throw new RuntimeException('User not found.');
if (($old['role'] ?? '') === 'admin' && bg_admin_count($pdo) <= 1) throw new RuntimeException('At least one administrator must remain.');
if (($old['username'] ?? '') === bg_current_user()) throw new RuntimeException('You cannot delete the account currently in use.');
$stmt = $pdo->prepare('DELETE FROM users WHERE id = ?');
return $stmt->execute([$id]);
}
function bg_admin_emails() {
try {
$pdo = bg_user_pdo();
bg_users_schema($pdo);
$stmt = $pdo->query("SELECT email FROM users WHERE role = 'admin' AND email <> '' ORDER BY username ASC");
$rows = $stmt ? $stmt->fetchAll() : [];
$out = [];
foreach ($rows as $r) {
$email = trim((string)($r['email'] ?? ''));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) && !in_array($email, $out, true)) $out[] = $email;
}
return $out;
} catch (Throwable $e) {
return [];
}
}
?>