Files
racket-chatgpt-bootstrap/private/auth.php
T
www-data 475765e31f Move rendering into private templates
Add an explicit template renderer with HTML views and partials for the app, bootstrap, package, and catalog pages. Move shared reporting setup into config/reporting.php and relocate stylesheet assets under css/.
2026-05-26 12:50:26 +02:00

719 lines
19 KiB
PHP

<?php
/*
* auth.php
*
* Encapsulated authentication/user module for the Racket sandbox.
*
* Database:
* data/racket-sandbox.sqlite
*
* Public model:
* RacketSandboxAuth
* RacketSandboxUser
*
* Outside code should not inspect DB rows directly.
*/
require_once __DIR__ . '/Template.php';
class RacketSandboxAuthException extends Exception
{
}
class RacketSandboxUser
{
private $row;
public function __construct($row)
{
$this->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.',
RacketSandboxTemplate::renderFile('partials/login-link.html', array('login_label' => 'Login'))
);
}
private function messageHtml($title, $message, $extra = '')
{
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo RacketSandboxTemplate::renderFile('simple-message.html', array(
'language' => 'en',
'title' => $title,
'message' => $message,
'extra_html' => $extra,
));
exit;
}
}