Files
Protocolbot e514e8aca3 feat: ctrl+click for new tabs, delete redirect, password change
- Protocol and collection pills now use <a href> instead of <div onclick>,
  so ctrl+click (or cmd+click) opens in a new tab, and middle-click works
- Applied consistently: library, collection detail, user profile
- After deleting a protocol or collection, currentRoute is reset so
  navigation back to the list actually works
- Password change: when viewing your own profile, a 'Change Password'
  button appears. Opens a modal requiring current password + new password
  + confirmation. New API endpoint POST /api/auth/password validates
  the current password and updates the hash.
2026-07-08 13:50:11 -06:00

251 lines
7.9 KiB
PHP

<?php
/**
* Simple session-based authentication.
* Supports registration, login, SSO callback, and role checks.
*/
// Set secure session cookie params before starting
session_set_cookie_params([
'lifetime' => 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']);
}
function handle_password_change(): void {
$user = current_user();
if (!$user) {
respond(401, ['error' => 'You must be logged in to change your password']);
return;
}
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$current_password = $data['current_password'] ?? '';
$new_password = $data['new_password'] ?? '';
if (strlen($new_password) < 8 || strlen($new_password) > 72) {
respond(400, ['error' => 'New password must be between 8 and 72 characters']);
return;
}
// Verify current password
$row = db_one("SELECT password_hash FROM users WHERE id = ?", [$user['id']]);
if (!$row || !password_verify($current_password, $row['password_hash'] ?? '')) {
respond(401, ['error' => 'Current password is incorrect']);
return;
}
// Update password
db_update('users', ['password_hash' => password_hash($new_password, PASSWORD_DEFAULT)], 'id = ?', [$user['id']]);
respond(200, ['ok' => true]);
}