Files
freedoms4-backend-public/auth.php

647 lines
26 KiB
PHP

<?php
/**
* auth.php — Login, Sign-Up, OTP/Welcome emails, Prosody XMPP account backend for freedoms4.org
*
* Sign-up flow:
* 1. POST { action: "send_otp", email }
* 2. POST { action: "signup", username, email, password, otp }
*/
// ── Credentials from env file ─────────────────────────
$env_file = '/etc/freedoms4/auth.env';
if (!is_readable($env_file)) {
error_log('auth.php: env file not readable: ' . $env_file);
http_response_code(503);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'message' => 'Server configuration error.']);
exit;
}
$env = [];
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim($v);
}
define('DB_HOST', $env['DB_HOST'] ?? '127.0.0.1');
define('DB_PORT', $env['DB_PORT'] ?? '5432');
define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4');
define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user');
define('DB_PASS', $env['DB_PASS'] ?? '');
define('PROSODY_DB_NAME', $env['PROSODY_DB_NAME'] ?? 'prosody');
define('PROSODY_DB_USER', $env['PROSODY_DB_USER'] ?? 'prosody');
define('PROSODY_DB_PASS', $env['PROSODY_DB_PASS'] ?? '');
define('PROSODY_HOST', $env['PROSODY_HOST'] ?? 'freedoms4.org');
// ── Constants ──
define('SESSION_NAME', 'f4_session');
define('SESSION_SECURE', true);
define('SESSION_SAMESITE', 'None');
define('SESSION_TTL', 86400); // 24 hours
define('OTP_FROM', 'no-reply@freedoms4.org');
define('OTP_TTL', 600); // 10 minutes
define('OTP_MAX_DAY', 5); // max OTPs per email per 24 h
define('OTP_MAX_FAILS', 10); // max failed OTP attempts per IP before lockout
define('LOGIN_MAX_FAILS', 10); // max failed login attempts per username before lockout (independent of IP)
define('MAX_BODY_BYTES', 4096);
// ── CORS ──
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
if (!$origin || !in_array($origin, $allowed_origins, true)) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'message' => 'Forbidden.']);
exit;
}
if ($origin) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Credentials: true');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// ── Helpers ──
function json_out(array $data, int $status = 200): never {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data);
exit;
}
function start_session(): void {
if (session_status() === PHP_SESSION_NONE) {
session_name(SESSION_NAME);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => SESSION_SECURE,
'httponly' => true,
'samesite' => SESSION_SAMESITE,
]);
session_start();
}
}
function db_connect(): PDO {
static $pdo = null;
if ($pdo !== null) return $pdo;
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', DB_HOST, DB_PORT, DB_NAME);
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
error_log('DB connection failed: ' . $e->getMessage());
json_out(['success' => false, 'message' => 'Database unavailable.'], 503);
}
return $pdo;
}
function prosody_db_connect(): PDO {
static $pdo = null;
if ($pdo !== null) return $pdo;
$dsn = sprintf('pgsql:host=127.0.0.1;port=5432;dbname=%s', PROSODY_DB_NAME);
// Throws on failure — caller must catch and handle
$pdo = new PDO($dsn, PROSODY_DB_USER, PROSODY_DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
// Rate limiting via APCu (per-IP, persistent across requests within the window)
// Falls back to session-based if APCu is unavailable.
function rate_limit(string $ip, int $max, int $window): bool {
$key = 'rl_' . hash('sha256', $ip);
if (function_exists('apcu_fetch')) {
$count = apcu_fetch($key, $ok);
if (!$ok) {
apcu_store($key, 1, $window);
return true;
}
if ($count >= $max) return false;
apcu_inc($key);
return true;
}
// Session fallback
$now = time();
$rl = $_SESSION[$key] ?? ['count' => 0, 'window_start' => $now];
if ($now - $rl['window_start'] > $window) {
$rl = ['count' => 0, 'window_start' => $now];
}
$rl['count']++;
$_SESSION[$key] = $rl;
return $rl['count'] <= $max;
}
// OTP failure tracking via APCu (per-IP lockout after OTP_MAX_FAILS attempts)
function otp_fail_count(string $ip): int {
$key = 'otpfail_' . hash('sha256', $ip);
if (function_exists('apcu_fetch')) {
$count = apcu_fetch($key, $ok);
return $ok ? (int)$count : 0;
}
return $_SESSION[$key] ?? 0;
}
function otp_fail_increment(string $ip): void {
$key = 'otpfail_' . hash('sha256', $ip);
if (function_exists('apcu_inc')) {
if (!apcu_fetch($key)) {
apcu_store($key, 1, 3600);
} else {
apcu_inc($key);
}
return;
}
$_SESSION[$key] = ($_SESSION[$key] ?? 0) + 1;
}
function otp_fail_reset(string $ip): void {
$key = 'otpfail_' . hash('sha256', $ip);
if (function_exists('apcu_delete')) {
apcu_delete($key);
return;
}
unset($_SESSION[$key]);
}
// Per-account login failure tracking — independent of the global per-IP
// rate limit, so a brute-force attack distributed across many IPs still
// gets locked out once a single account has too many failed attempts.
function login_fail_count(string $username): int {
$key = 'loginfail_' . hash('sha256', strtolower($username));
if (function_exists('apcu_fetch')) {
$count = apcu_fetch($key, $ok);
return $ok ? (int)$count : 0;
}
return $_SESSION[$key] ?? 0;
}
function login_fail_increment(string $username): void {
$key = 'loginfail_' . hash('sha256', strtolower($username));
if (function_exists('apcu_inc')) {
if (!apcu_fetch($key)) {
apcu_store($key, 1, 900); // 15 minute lockout window
} else {
apcu_inc($key);
}
return;
}
$_SESSION[$key] = ($_SESSION[$key] ?? 0) + 1;
}
function login_fail_reset(string $username): void {
$key = 'loginfail_' . hash('sha256', strtolower($username));
if (function_exists('apcu_delete')) {
apcu_delete($key);
return;
}
unset($_SESSION[$key]);
}
function create_xmpp_account(string $username, string $password): bool {
try {
$pdo = prosody_db_connect();
$host = PROSODY_HOST;
$now = time();
// Never overwrite an existing account
$stmt = $pdo->prepare(
"SELECT 1 FROM prosody WHERE host = :h AND \"user\" = :u AND store = 'accounts' LIMIT 1"
);
$stmt->execute([':h' => $host, ':u' => $username]);
if ($stmt->fetch()) {
return true;
}
// Derive SCRAM-SHA-1 keys
$salt = sprintf(
'%08x-%04x-%04x-%04x-%012x',
random_int(0, 0xffffffff),
random_int(0, 0xffff),
random_int(0x4000, 0x4fff),
random_int(0x8000, 0xbfff),
random_int(0, 0xffffffffffff)
);
$iterations = 10000;
$salted_pw = hash_pbkdf2('sha1', $password, $salt, $iterations, 0, true);
$client_key = hash_hmac('sha1', 'Client Key', $salted_pw, true);
$stored_key = sha1($client_key);
$server_key = hash_hmac('sha1', 'Server Key', $salted_pw, false);
$insert = $pdo->prepare(
"INSERT INTO prosody (host, \"user\", store, key, type, value)
VALUES (:h, :u, 'accounts', :k, :t, :v)
ON CONFLICT (host, \"user\", store, key) DO UPDATE SET type = EXCLUDED.type, value = EXCLUDED.value"
);
$rows = [
['salt', 'string', $salt],
['iteration_count', 'number', (string)$iterations],
['stored_key', 'string', $stored_key],
['server_key', 'string', $server_key],
['created', 'number', (string)$now],
['updated', 'number', (string)$now],
];
$pdo->beginTransaction();
foreach ($rows as [$key, $type, $value]) {
$insert->execute([':h' => $host, ':u' => $username, ':k' => $key, ':t' => $type, ':v' => $value]);
}
$pdo->commit();
return true;
} catch (Exception $e) {
error_log("create_xmpp_account failed for {$username}: " . $e->getMessage());
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
return false;
}
}
// ── Only accept POST ──
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_out(['success' => false, 'message' => 'Method not allowed.'], 405);
}
// ── Request body size cap ──
$content_length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($content_length > MAX_BODY_BYTES) {
json_out(['success' => false, 'message' => 'Request too large.'], 413);
}
$raw = fread(fopen('php://input', 'r'), MAX_BODY_BYTES + 1);
if (strlen($raw) > MAX_BODY_BYTES) {
json_out(['success' => false, 'message' => 'Request too large.'], 413);
}
$body = json_decode($raw, true);
if (!is_array($body)) {
json_out(['success' => false, 'message' => 'Invalid request body.'], 400);
}
$action = $body['action'] ?? '';
// ── Session + rate limiting ──
start_session();
$now = time();
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!rate_limit($ip, 20, 900)) {
json_out(['success' => false, 'message' => 'Too many requests. Please wait a few minutes.'], 429);
}
// ── 24 h session expiry ──
if (!empty($_SESSION['user_id'])) {
$last_seen = $_SESSION['last_seen'] ?? 0;
if ($now - $last_seen > SESSION_TTL) {
if (!empty($_SESSION['db_session_id'])) {
try {
db_connect()->prepare(
"UPDATE user_sessions SET logged_out_at = NOW() WHERE id = :sid AND logged_out_at IS NULL"
)->execute([':sid' => $_SESSION['db_session_id']]);
} catch (Exception $e) {}
}
session_destroy();
start_session();
json_out(['success' => false, 'message' => 'Session expired. Please log in again.'], 401);
}
if ($now - $last_seen > 60) {
$_SESSION['last_seen'] = $now;
if (!empty($_SESSION['db_session_id'])) {
try {
db_connect()->prepare(
"UPDATE user_sessions SET last_seen_at = NOW() WHERE id = :sid"
)->execute([':sid' => $_SESSION['db_session_id']]);
} catch (Exception $e) {}
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Send OTP
// ════════════════════════════════════════════════════════════════════════════
if ($action === 'send_otp') {
$username = trim($body['username'] ?? '');
$email = trim($body['email'] ?? '');
if ($username !== '' && !preg_match('/^[a-zA-Z0-9_\-]{3,32}$/', $username)) {
json_out(['success' => false, 'message' => 'Username must be 3-32 characters: letters, numbers, _ or -.']);
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_out(['success' => false, 'message' => 'A valid email address is required.']);
}
$pdo = db_connect();
$stmt = $pdo->prepare('SELECT blocked FROM users WHERE username = :u OR email = :e ORDER BY blocked DESC LIMIT 1');
$stmt->execute([':u' => $username, ':e' => $email]);
$user = $stmt->fetch();
if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) {
json_out(['success' => false, 'message' => 'This account has been blocked.']);
}
if ($user) {
json_out(['success' => false, 'message' => 'Username or email is already taken.']);
}
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM email_otps WHERE email = :e AND created_at > NOW() - INTERVAL '24 hours'"
);
$stmt->execute([':e' => $email]);
if ((int)$stmt->fetchColumn() >= OTP_MAX_DAY) {
json_out(['success' => false, 'message' => 'Too many OTP requests for this email. Please try again tomorrow.'], 429);
}
$pdo->prepare("DELETE FROM email_otps WHERE email = :e AND used = FALSE")->execute([':e' => $email]);
$otp = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$otp_hash = password_hash($otp, PASSWORD_BCRYPT, ['cost' => 10]);
$pdo->prepare(
"INSERT INTO email_otps (email, otp_hash, expires_at, used)
VALUES (:e, :h, NOW() + INTERVAL '10 minutes', FALSE)"
)->execute([':e' => $email, ':h' => $otp_hash]);
$subject = 'Freedoms4 sign up OTP';
$message =
"Hello,\n\n" .
"Your OTP to create a freedoms4.org account is:\n\n" .
"{$otp}\n\n" .
"This code expires in 10 minutes. Do not share it with anyone.\n\n" .
"If you did not request this, you can safely ignore this email.\n\n" .
"freedoms4.org";
$headers = implode("\r\n", [
'From: freedoms4.org <' . OTP_FROM . '>',
'Reply-To: ' . OTP_FROM,
'Cc: hyzen@freedoms4.org',
'X-Mailer: PHP/' . PHP_VERSION,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
]);
if (!mail($email, $subject, $message, $headers)) {
error_log("OTP mail() failed for: {$email}");
json_out(['success' => false, 'message' => 'Failed to send OTP email. Please try again.'], 500);
}
json_out(['success' => true, 'message' => 'OTP sent. Please check your inbox (and spam folder).']);
}
// ════════════════════════════════════════════════════════════════════════════
// Login
// ════════════════════════════════════════════════════════════════════════════
if ($action === 'login') {
$username = trim($body['username'] ?? '');
$password = $body['password'] ?? '';
if ($username === '' || $password === '') {
json_out(['success' => false, 'message' => 'Username and password are required.']);
}
// Per-account brute-force lockout — checked before touching the DB, and
// independent of the global per-IP limit above.
if (login_fail_count($username) >= LOGIN_MAX_FAILS) {
json_out(['success' => false, 'message' => 'Too many failed attempts for this account. Please wait 15 minutes and try again.'], 429);
}
$pdo = db_connect();
$stmt = $pdo->prepare('SELECT id, username, password_hash, blocked FROM users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]);
$user = $stmt->fetch();
$hash = $user['password_hash'] ?? '$2y$12$invalidhashpadding000000000000000000000000000000000000000';
if (!$user || !password_verify($password, $hash)) {
login_fail_increment($username);
json_out(['success' => false, 'message' => 'Invalid username or password.']);
}
// Correct password — clear the failure counter for this account.
login_fail_reset($username);
if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) {
json_out(['success' => false, 'message' => 'This account has been blocked.']);
}
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['last_seen'] = $now;
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512);
$session_hash = hash('sha256', session_id());
$stmt = $pdo->prepare(
"INSERT INTO user_sessions (user_id, session_id, logged_in_at, last_seen_at, ip_address, user_agent)
VALUES (:uid, :sid, NOW(), NOW(), :ip, :ua)"
);
$stmt->execute([':uid' => $user['id'], ':sid' => $session_hash, ':ip' => $ip, ':ua' => $ua]);
$_SESSION['db_session_id'] = $pdo->lastInsertId();
json_out(['success' => true, 'redirect' => '/']);
}
// ════════════════════════════════════════════════════════════════════════════
// Signup + XMPP + Email accounts creation
// ════════════════════════════════════════════════════════════════════════════
if ($action === 'signup') {
$username = trim($body['username'] ?? '');
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
$otp = trim($body['otp'] ?? '');
$terms_agreed = !empty($body['terms_agreed']);
if ($username === '') {
json_out(['success' => false, 'message' => 'Username is required.']);
}
if (!preg_match('/^[a-zA-Z0-9_\-]{3,32}$/', $username)) {
json_out(['success' => false, 'message' => 'Username must be 3-32 characters: letters, numbers, _ or -.']);
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_out(['success' => false, 'message' => 'A valid email address is required.']);
}
if (strlen($password) < 8) {
json_out(['success' => false, 'message' => 'Password must be at least 8 characters.']);
}
if ($otp === '' || !preg_match('/^\d{6}$/', $otp)) {
json_out(['success' => false, 'message' => 'A 6-digit verification code is required.']);
}
if (!$terms_agreed) {
json_out(['success' => false, 'message' => 'You must agree to the terms and conditions.']);
}
// OTP brute-force lockout
if (otp_fail_count($ip) >= OTP_MAX_FAILS) {
json_out(['success' => false, 'message' => 'Too many failed attempts. Please request a new code.'], 429);
}
$pdo = db_connect();
$stmt = $pdo->prepare('SELECT blocked FROM users WHERE username = :u OR email = :e ORDER BY blocked DESC LIMIT 1');
$stmt->execute([':u' => $username, ':e' => $email]);
$user = $stmt->fetch();
if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) {
json_out(['success' => false, 'message' => 'This account has been blocked.']);
}
if ($user) {
json_out(['success' => false, 'message' => 'Username or email is already taken.']);
}
// Verify OTP
$stmt = $pdo->prepare(
"SELECT id, otp_hash FROM email_otps
WHERE email = :e AND used = FALSE AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1"
);
$stmt->execute([':e' => $email]);
$otp_row = $stmt->fetch();
if (!$otp_row || !password_verify($otp, $otp_row['otp_hash'])) {
otp_fail_increment($ip);
json_out(['success' => false, 'message' => 'Invalid or expired verification code.']);
}
// Valid OTP — reset failure counter
otp_fail_reset($ip);
// Check uniqueness
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :u OR email = :e LIMIT 1');
$stmt->execute([':u' => $username, ':e' => $email]);
if ($stmt->fetch()) {
json_out(['success' => false, 'message' => 'Username or email is already taken.']);
}
// Create freedoms4 account + mark OTP used
$pdo->beginTransaction();
try {
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$pdo->prepare(
'INSERT INTO users (username, email, password_hash, terms_agreed, created_at) VALUES (:u, :e, :h, :t, NOW())'
)->execute([':u' => $username, ':e' => $email, ':h' => $hash, ':t' => $terms_agreed ? 'true' : 'false']);
$pdo->prepare("UPDATE email_otps SET used = TRUE WHERE id = :id")
->execute([':id' => $otp_row['id']]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
error_log('signup transaction failed: ' . $e->getMessage());
json_out(['success' => false, 'message' => 'Account creation failed. Please try again.'], 500);
}
// Regenerate session after successful signup (#5)
session_regenerate_id(true);
// Create XMPP account (JID localparts are lowercased per XMPP convention)
$xmpp_username = strtolower($username);
if (!create_xmpp_account($xmpp_username, $password)) {
error_log("XMPP account creation failed for new user: {$xmpp_username}");
// Don't fail the signup — user account exists, XMPP can be fixed manually
}
// ── Create Dovecot virtual email account ──
// Sudoers: www-data ALL=(root) NOPASSWD: /usr/local/bin/email-account-create
$safe_user = escapeshellarg($username);
$safe_pw = escapeshellarg($password);
$email_out = shell_exec("sudo /usr/local/bin/email-account-create {$safe_user} {$safe_pw} 2>&1");
if (!in_array(trim($email_out ?? ''), ['created', 'exists', 'system-user'])) {
error_log("email-account-create failed for {$username}: " . ($email_out ?? 'null'));
// Don't fail the signup — email can be fixed manually
}
// Welcome email
$welcome_headers = implode("\r\n", [
'From: hyzen <hyzen@freedoms4.org>',
'Reply-To: hyzen@freedoms4.org',
'Cc: hyzen@freedoms4.org',
'X-Mailer: PHP/' . PHP_VERSION,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
]);
$welcome_message =
"Hi {$username},\n\n" .
"Welcome aboard, thank you for signing up!\n\n" .
"Your credentials for the services:\n\n" .
"Email ID: {$username}@freedoms4.org\n" .
"XMPP JID: {$xmpp_username}@freedoms4.org\n" .
"Passwords: Use the same password that you used during registration.\n\n" .
"If you have any questions, I'm here to help:\n" .
"Email: mailto:hyzen@freedoms4.org\n".
"XMPP: xmpp:hyzen@freedoms4.org\n" .
"IRC/Liberachat: hyzen, #freedoms4\n\n" .
"Best regards,\n" .
"hyzen, freedoms4.org.";
if (!mail($email, "Welcome to freedoms4.org", $welcome_message, $welcome_headers)) {
error_log("Welcome mail() failed for: {$email}");
}
json_out(['success' => true]);
}
// ════════════════════════════════════════════════════════════════════════════
// Check session
// ════════════════════════════════════════════════════════════════════════════
if ($action === 'check_session') {
if (empty($_SESSION['user_id'])) {
json_out(['valid' => false]);
}
// Verify user still exists in DB
try {
$pdo = db_connect();
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $_SESSION['user_id']]);
if (!$stmt->fetch()) {
// User deleted — destroy session
$_SESSION = [];
session_destroy();
json_out(['valid' => false]);
}
} catch (Exception $e) {
// DB unavailable — don't force logout, just report invalid so frontend can retry
json_out(['valid' => false, 'db_error' => true]);
}
json_out(['valid' => true, 'username' => $_SESSION['username']]);
}
// ════════════════════════════════════════════════════════════════════════════
// Logout
// ════════════════════════════════════════════════════════════════════════════
// Logout
// ════════════════════════════════════════════════════════════════════════════
if ($action === 'logout') {
if (!empty($_SESSION['db_session_id'])) {
try {
db_connect()->prepare(
"UPDATE user_sessions SET logged_out_at = NOW() WHERE id = :sid AND logged_out_at IS NULL"
)->execute([':sid' => $_SESSION['db_session_id']]);
} catch (Exception $e) {}
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
json_out(['success' => true, 'redirect' => '/']);
}
json_out(['success' => false, 'message' => 'Unknown action.'], 400);