0, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', 'secure' => !empty($_SERVER['HTTPS']) || getenv('FORCE_HTTPS'), ]); session_start(); function current_user(): ?array { if (!isset($_SESSION['user_id'])) return null; $user = db_one("SELECT id, username, display_name, bio, role, created_at FROM users WHERE id = ?", [$_SESSION['user_id']]); return $user ?: null; } function require_auth(): array { $user = current_user(); if (!$user) { http_response_code(401); echo json_encode(['error' => 'Authentication required']); exit; } return $user; } function require_admin(): array { $user = require_auth(); if ($user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Admin access required']); exit; } return $user; } function require_member(): array { $user = require_auth(); if ($user['role'] === 'viewer') { http_response_code(403); echo json_encode(['error' => 'Member access required']); exit; } return $user; } // --- CSRF protection --- function check_csrf(): void { $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; if (!in_array($method, ['POST', 'PUT', 'DELETE'])) return; $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $referer = $_SERVER['HTTP_REFERER'] ?? ''; $expected_host = $_SERVER['HTTP_HOST'] ?? ''; if ($origin) { $origin_host = parse_url($origin, PHP_URL_HOST); if ($origin_host !== $expected_host) { http_response_code(403); echo json_encode(['error' => 'Cross-origin requests are not allowed']); exit; } } elseif ($referer) { $referer_host = parse_url($referer, PHP_URL_HOST); if ($referer_host !== $expected_host) { http_response_code(403); echo json_encode(['error' => 'Cross-origin requests are not allowed']); exit; } } } // --- Rate limiting for login --- function check_login_rate(string $username): void { $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $key = 'login_attempts_' . md5($ip . $username); $file = sys_get_temp_dir() . '/' . $key; $attempts = file_exists($file) ? (int)file_get_contents($file) : 0; if ($attempts >= 10) { http_response_code(429); echo json_encode(['error' => 'Too many login attempts. Try again later.']); exit; } } function record_login_failure(string $username): void { $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $key = 'login_attempts_' . md5($ip . $username); $file = sys_get_temp_dir() . '/' . $key; $attempts = file_exists($file) ? (int)file_get_contents($file) : 0; file_put_contents($file, ($attempts + 1)); touch($file, time() + 900); } function clear_login_rate(string $username): void { $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $key = 'login_attempts_' . md5($ip . $username); $file = sys_get_temp_dir() . '/' . $key; if (file_exists($file)) unlink($file); } function handle_register(): void { $data = json_body(); $username = trim($data['username'] ?? ''); $password = $data['password'] ?? ''; $display_name = trim($data['display_name'] ?? ''); if (!preg_match('/^[a-zA-Z0-9_]{3,32}$/', $username)) { respond(400, ['error' => 'Username must be 3-32 characters: letters, numbers, underscore']); return; } if (strlen($password) < 8 || strlen($password) > 72) { respond(400, ['error' => 'Password must be 8-72 characters']); return; } if (db_one("SELECT id FROM users WHERE username = ?", [$username])) { respond(409, ['error' => 'Username already taken']); return; } $user_count = (int) db_one("SELECT COUNT(*) as c FROM users")['c']; $role = 'member'; if ($user_count === 0) { $bootstrap_token = getenv('ADMIN_BOOTSTRAP_TOKEN'); if ($bootstrap_token) { $provided = $data['bootstrap_token'] ?? ''; if ($provided !== $bootstrap_token) { respond(403, ['error' => 'First registration requires an admin bootstrap token. Set ADMIN_BOOTSTRAP_TOKEN env var and include it in the registration request.']); return; } $role = 'admin'; } else { error_log('Protocol Droid: First user registered as admin without bootstrap token. Set ADMIN_BOOTSTRAP_TOKEN for production.'); $role = 'admin'; } } if ($user_count > 0) { $reg_enabled = db_one("SELECT value FROM config WHERE `key` = 'registration_enabled'"); if ($reg_enabled && $reg_enabled['value'] !== 'true') { respond(403, ['error' => 'Registration is disabled on this instance']); return; } $require_approval = config_get('require_approval', 'false') === 'true'; if ($require_approval) { $role = 'viewer'; } } $id = db_insert('users', [ 'username' => $username, 'display_name' => $display_name ?: $username, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'role' => $role, ]); session_regenerate_id(true); $_SESSION['user_id'] = $id; respond(201, [ 'id' => $id, 'username' => $username, 'display_name' => $display_name ?: $username, 'role' => $role, ]); } function handle_login(): void { $data = json_body(); $username = trim($data['username'] ?? ''); $password = $data['password'] ?? ''; check_login_rate($username); $user = db_one("SELECT * FROM users WHERE username = ?", [$username]); if (!$user || !password_verify($password, $user['password_hash'] ?? '')) { record_login_failure($username); respond(401, ['error' => 'Invalid username or password']); return; } clear_login_rate($username); session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; respond(200, [ 'id' => $user['id'], 'username' => $user['username'], 'display_name' => $user['display_name'], 'role' => $user['role'], ]); } function handle_logout(): void { $_SESSION = []; session_destroy(); if (ini_get('session.use_cookies')) { $params = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly'] ); } respond(200, ['ok' => true]); } function handle_me(): void { $user = current_user(); if (!$user) { respond(200, ['user' => null]); return; } respond(200, ['user' => $user]); } function handle_sso_callback(): void { respond(501, ['error' => 'SSO not configured on this instance. Configure SSO in api/auth.php']); }