02794e565e
A tool for authoring, sharing, and curating social protocols. Features: - Protocol library with search, tag filtering, and sort by date/relevance - Protocol authoring with structured fields (title, description, steps, outcome, practice, source, tags) - Protocol forking with provenance tracking - Collection creation with searchable protocol picker and ordering - User accounts with roles (admin, member, viewer) - YAML import/export for portability - Self-hostable on LAMP/Cloudron, works in subdirectories - Responsive design with hamburger menu on mobile - About page Security: - CSRF protection via Origin/Referer validation - Session regeneration on login/register - Secure session cookie params (HttpOnly, SameSite, Secure) - Visibility enforcement on private/unlisted items - YAML object injection hardening - Login rate limiting - Path traversal protection - Input validation and length clamping - Vote value constraining Stack: PHP 8.x + MySQL/MariaDB, vanilla JS frontend, no external dependencies. Hippocratic License (HL3-CORE).
223 lines
6.9 KiB
PHP
Executable File
223 lines
6.9 KiB
PHP
Executable File
<?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']);
|
|
} |