feat: initial release of Protocol Droid v0.2.0
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).
This commit is contained in:
Executable
+223
@@ -0,0 +1,223 @@
|
||||
<?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']);
|
||||
}
|
||||
Reference in New Issue
Block a user