row = is_array($row) ? $row : array(); } public function id() { return (int)($this->row['id'] ?? 0); } public function email() { return (string)($this->row['email'] ?? ''); } public function fullName() { return (string)($this->row['full_name'] ?? ''); } public function displayName() { $name = $this->fullName(); $email = $this->email(); if ($name !== '' && $email !== '') { return $name . ' <' . $email . '>'; } if ($name !== '') { return $name; } return $email; } public function isAdmin() { return (int)($this->row['is_admin'] ?? 0) === 1; } public function isEnabled() { return (int)($this->row['is_enabled'] ?? 0) === 1; } public function createdAt() { return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null; } public function updatedAt() { return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null; } public function lastLoginAt() { return isset($this->row['last_login_at']) && $this->row['last_login_at'] !== null ? (int)$this->row['last_login_at'] : null; } public function sessionExpiresAt() { return isset($this->row['session_expires_at']) && $this->row['session_expires_at'] !== null ? (int)$this->row['session_expires_at'] : null; } public function raw() { return $this->row; } } class RacketSandboxAuth { private $db; private $sessionCookieName; private $sessionTtlSeconds; public function __construct($dbFile, $options = array()) { $dir = dirname($dbFile); if (!is_dir($dir)) { if (!mkdir($dir, 0700, true)) { throw new RacketSandboxAuthException('Could not create database directory: ' . $dir); } } $this->sessionCookieName = isset($options['cookie_name']) ? (string)$options['cookie_name'] : 'rkt_sandbox_session'; $this->sessionTtlSeconds = isset($options['session_ttl']) ? (int)$options['session_ttl'] : 12 * 3600; $this->db = new PDO('sqlite:' . $dbFile); $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->db->exec('PRAGMA journal_mode = WAL'); $this->db->exec('PRAGMA busy_timeout = 5000'); $this->init(); } private function init() { $this->db->exec( 'CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, full_name TEXT NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, is_enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER NULL )' ); $this->migrateUsersTable(); $this->db->exec( 'CREATE TABLE IF NOT EXISTS auth_sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, user_agent TEXT NULL, remote_addr TEXT NULL, FOREIGN KEY(user_id) REFERENCES users(id) )' ); $this->db->exec( 'CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id)' ); $this->db->exec( 'CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at)' ); } private function migrateUsersTable() { $cols = $this->tableColumns('users'); if (isset($cols['username']) && !isset($cols['email'])) { $this->db->exec('ALTER TABLE users RENAME COLUMN username TO email'); $cols = $this->tableColumns('users'); } if (!isset($cols['full_name'])) { $this->db->exec("ALTER TABLE users ADD COLUMN full_name TEXT NOT NULL DEFAULT ''"); $this->db->exec("UPDATE users SET full_name = email WHERE full_name = ''"); } } private function tableColumns($table) { $stmt = $this->db->query('PRAGMA table_info(' . $table . ')'); $cols = array(); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $cols[$row['name']] = true; } return $cols; } public function createUser($email, $fullName, $password, $isAdmin = false, $enabled = true) { $email = $this->safeEmail($email); $fullName = $this->safeFullName($fullName); $this->assertPasswordUsable($password); $now = time(); $hash = password_hash($password, PASSWORD_DEFAULT); if ($hash === false) { throw new RacketSandboxAuthException('Could not hash password.'); } $stmt = $this->db->prepare( 'INSERT INTO users (email, full_name, password_hash, is_admin, is_enabled, created_at, updated_at) VALUES (:email, :full_name, :password_hash, :is_admin, :is_enabled, :created_at, :updated_at)' ); $stmt->execute(array( ':email' => $email, ':full_name' => $fullName, ':password_hash' => $hash, ':is_admin' => $isAdmin ? 1 : 0, ':is_enabled' => $enabled ? 1 : 0, ':created_at' => $now, ':updated_at' => $now, )); return $this->getUserById((int)$this->db->lastInsertId()); } public function login($email, $password) { $email = $this->safeEmail($email); $stmt = $this->db->prepare( 'SELECT * FROM users WHERE email = :email' ); $stmt->execute(array(':email' => $email)); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new RacketSandboxAuthException('Invalid email address or password.'); } if ((int)$row['is_enabled'] !== 1) { throw new RacketSandboxAuthException('This account is disabled.'); } if (!password_verify($password, $row['password_hash'])) { throw new RacketSandboxAuthException('Invalid email address or password.'); } if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) { $this->setPasswordById((int)$row['id'], $password, false); } $token = $this->createSession((int)$row['id']); $this->setSessionCookie($token); $stmt = $this->db->prepare( 'UPDATE users SET last_login_at = :last_login_at WHERE id = :id' ); $stmt->execute(array( ':last_login_at' => time(), ':id' => (int)$row['id'], )); return $this->getUserById((int)$row['id']); } public function logout() { $token = $this->cookieToken(); if ($token !== null) { $this->deleteSession($token); } $this->clearSessionCookie(); } public function currentUser() { $token = $this->cookieToken(); if ($token === null || !$this->validTokenSyntax($token)) { return null; } $stmt = $this->db->prepare( 'SELECT u.id, u.email, u.full_name, u.is_admin, u.is_enabled, u.created_at, u.updated_at, u.last_login_at, s.expires_at AS session_expires_at FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = :token' ); $stmt->execute(array(':token' => $token)); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } if ((int)$row['is_enabled'] !== 1) { $this->deleteSession($token); return null; } if (time() > (int)$row['session_expires_at']) { $this->deleteSession($token); return null; } $this->touchSession($token); return new RacketSandboxUser($row); } public function requireLoginHtml() { $user = $this->currentUser(); if ($user !== null) { return $user; } $this->loginRequiredHtml(); } public function requireAdminHtml() { $user = $this->requireLoginHtml(); if ($user->isAdmin()) { return $user; } $this->messageHtml( 'Not available', 'Admin rights are required for this page.' ); } public function listUsers() { $stmt = $this->db->query( 'SELECT id, email, full_name, is_admin, is_enabled, created_at, updated_at, last_login_at FROM users ORDER BY email COLLATE NOCASE' ); $out = array(); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $out[] = new RacketSandboxUser($row); } return $out; } public function getUser($id) { return $this->getUserById($id); } public function setAdmin($userId, $isAdmin) { $this->updateUserFlag($userId, 'is_admin', $isAdmin); } public function setEnabled($userId, $isEnabled) { $this->updateUserFlag($userId, 'is_enabled', $isEnabled); if (!$isEnabled) { $stmt = $this->db->prepare( 'DELETE FROM auth_sessions WHERE user_id = :user_id' ); $stmt->execute(array(':user_id' => (int)$userId)); } } public function updateUser($userId, $email, $fullName) { $userId = (int)$userId; $email = $this->safeEmail($email); $fullName = $this->safeFullName($fullName); $stmt = $this->db->prepare( 'UPDATE users SET email = :email, full_name = :full_name, updated_at = :updated_at WHERE id = :id' ); $stmt->execute(array( ':email' => $email, ':full_name' => $fullName, ':updated_at' => time(), ':id' => $userId, )); return $this->getUserById($userId); } public function setPassword($email, $newPassword) { $email = $this->safeEmail($email); $stmt = $this->db->prepare( 'SELECT id FROM users WHERE email = :email' ); $stmt->execute(array(':email' => $email)); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new RacketSandboxAuthException('Unknown user: ' . $email); } $this->setPasswordById((int)$row['id'], $newPassword, true); } public function deleteUser($userId) { $userId = (int)$userId; $this->db->beginTransaction(); try { $stmt = $this->db->prepare( 'DELETE FROM auth_sessions WHERE user_id = :user_id' ); $stmt->execute(array(':user_id' => $userId)); $stmt = $this->db->prepare( 'DELETE FROM users WHERE id = :id' ); $stmt->execute(array(':id' => $userId)); $this->db->commit(); } catch (Throwable $e) { if ($this->db->inTransaction()) { $this->db->rollBack(); } throw $e; } } public function cleanupSessions() { $stmt = $this->db->prepare( 'DELETE FROM auth_sessions WHERE expires_at < :now' ); $stmt->execute(array(':now' => time())); return $stmt->rowCount(); } private function getUserById($id) { $stmt = $this->db->prepare( 'SELECT id, email, full_name, is_admin, is_enabled, created_at, updated_at, last_login_at FROM users WHERE id = :id' ); $stmt->execute(array(':id' => (int)$id)); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } return new RacketSandboxUser($row); } private function createSession($userId) { $token = bin2hex(random_bytes(32)); $now = time(); $stmt = $this->db->prepare( 'INSERT INTO auth_sessions (token, user_id, created_at, expires_at, last_seen_at, user_agent, remote_addr) VALUES (:token, :user_id, :created_at, :expires_at, :last_seen_at, :user_agent, :remote_addr)' ); $stmt->execute(array( ':token' => $token, ':user_id' => (int)$userId, ':created_at' => $now, ':expires_at' => $now + $this->sessionTtlSeconds, ':last_seen_at' => $now, ':user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, ':remote_addr' => $_SERVER['REMOTE_ADDR'] ?? null, )); return $token; } private function touchSession($token) { $now = time(); $stmt = $this->db->prepare( 'UPDATE auth_sessions SET last_seen_at = :last_seen_at, expires_at = :expires_at WHERE token = :token' ); $stmt->execute(array( ':last_seen_at' => $now, ':expires_at' => $now + $this->sessionTtlSeconds, ':token' => $token, )); } private function deleteSession($token) { $stmt = $this->db->prepare( 'DELETE FROM auth_sessions WHERE token = :token' ); $stmt->execute(array(':token' => $token)); } private function setPasswordById($userId, $newPassword, $deleteSessions) { $this->assertPasswordUsable($newPassword); $hash = password_hash($newPassword, PASSWORD_DEFAULT); if ($hash === false) { throw new RacketSandboxAuthException('Could not hash password.'); } $stmt = $this->db->prepare( 'UPDATE users SET password_hash = :password_hash, updated_at = :updated_at WHERE id = :id' ); $stmt->execute(array( ':password_hash' => $hash, ':updated_at' => time(), ':id' => (int)$userId, )); if ($deleteSessions) { $stmt = $this->db->prepare( 'DELETE FROM auth_sessions WHERE user_id = :user_id' ); $stmt->execute(array(':user_id' => (int)$userId)); } } private function updateUserFlag($userId, $column, $value) { if ($column !== 'is_admin' && $column !== 'is_enabled') { throw new RacketSandboxAuthException('Invalid flag column.'); } $stmt = $this->db->prepare( 'UPDATE users SET ' . $column . ' = :value, updated_at = :updated_at WHERE id = :id' ); $stmt->execute(array( ':value' => $value ? 1 : 0, ':updated_at' => time(), ':id' => (int)$userId, )); } private function cookieToken() { $token = $_COOKIE[$this->sessionCookieName] ?? null; if (!is_string($token) || !$this->validTokenSyntax($token)) { return null; } return $token; } private function setSessionCookie($token) { $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); setcookie($this->sessionCookieName, $token, array( 'expires' => time() + $this->sessionTtlSeconds, 'path' => '/', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', )); } private function clearSessionCookie() { $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); setcookie($this->sessionCookieName, '', array( 'expires' => time() - 3600, 'path' => '/', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', )); } private function validTokenSyntax($token) { return is_string($token) && preg_match('/^[a-f0-9]{64}$/', $token); } private function safeEmail($email) { $email = strtolower(trim((string)$email)); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new RacketSandboxAuthException('Invalid email address.'); } if (strlen($email) > 160) { throw new RacketSandboxAuthException('Email address is too long.'); } return $email; } private function safeFullName($fullName) { $fullName = trim((string)$fullName); if ($fullName === '') { throw new RacketSandboxAuthException('Full name is required.'); } if (strlen($fullName) > 160) { throw new RacketSandboxAuthException('Full name is too long.'); } return $fullName; } private function assertPasswordUsable($password) { if (!is_string($password) || strlen($password) < 10) { throw new RacketSandboxAuthException('Password must be at least 10 characters.'); } } private function loginRequiredHtml() { $this->messageHtml( 'Login required', 'Please log in to continue.', '

Login

' ); } private function messageHtml($title, $message, $extra = '') { http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo ' ' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

' . $extra . ' '; exit; } }