From 6f4cc4fada809509bfaf4bf1d8e9de6915309650 Mon Sep 17 00:00:00 2001 From: hyzen Date: Thu, 30 Jul 2026 10:42:14 +0200 Subject: [PATCH] Fix: auto-logout sessions. Add: support for keep me logged in checkbox. Update: hardening changes --- admin.php | 2 + auth.php | 85 +++++++++++++----------------------------- comments.php | 3 ++ full-setup.sh | 23 ++++++++++-- rollback-everything.sh | 7 ++-- uninstall.sh | 9 +++-- 6 files changed, 58 insertions(+), 71 deletions(-) diff --git a/admin.php b/admin.php index 306ede3..645fcfb 100644 --- a/admin.php +++ b/admin.php @@ -218,6 +218,8 @@ function xmpp_delete_backup(string $username): void { // ── Session + admin check ── if (session_status() === PHP_SESSION_NONE) { + ini_set('session.gc_maxlifetime', (string) REMEMBER_COOKIE_TTL); + ini_set('session.save_path', SESSION_SAVE_PATH); session_name(SESSION_NAME); session_set_cookie_params([ 'lifetime' => 0, diff --git a/auth.php b/auth.php index 12ced83..031614a 100644 --- a/auth.php +++ b/auth.php @@ -37,7 +37,6 @@ define('PROSODY_HOST', $env['PROSODY_HOST'] ?? 'freedoms4.org'); 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 @@ -79,6 +78,8 @@ function json_out(array $data, int $status = 200): never { function start_session(): void { if (session_status() === PHP_SESSION_NONE) { + ini_set('session.gc_maxlifetime', (string) REMEMBER_COOKIE_TTL); + ini_set('session.save_path', SESSION_SAVE_PATH); session_name(SESSION_NAME); session_set_cookie_params([ 'lifetime' => 0, @@ -121,31 +122,6 @@ function prosody_db_connect(): PDO { 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); @@ -295,41 +271,12 @@ if (!is_array($body)) { } $action = $body['action'] ?? ''; -// ── Session + rate limiting ── +// ── Session ── 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 @@ -406,6 +353,7 @@ if ($action === 'send_otp') { if ($action === 'login') { $username = trim($body['username'] ?? ''); $password = $body['password'] ?? ''; + $remember = !empty($body['remember']); if ($username === '' || $password === '') { json_out(['success' => false, 'message' => 'Username and password are required.']); @@ -439,7 +387,17 @@ if ($action === 'login') { session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; - $_SESSION['last_seen'] = $now; + $_SESSION['remember'] = $remember; + + if ($remember) { + setcookie(session_name(), session_id(), [ + 'expires' => $now + REMEMBER_COOKIE_TTL, + 'path' => '/', + 'secure' => SESSION_SECURE, + 'httponly' => true, + 'samesite' => SESSION_SAMESITE, + ]); + } $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512); $session_hash = hash('sha256', session_id()); @@ -600,17 +558,24 @@ if ($action === 'check_session') { json_out(['valid' => false]); } - // Verify user still exists in DB + // Verify user still exists in DB and isn't blocked try { $pdo = db_connect(); - $stmt = $pdo->prepare('SELECT 1 FROM users WHERE id = :id LIMIT 1'); + $stmt = $pdo->prepare('SELECT blocked FROM users WHERE id = :id LIMIT 1'); $stmt->execute([':id' => $_SESSION['user_id']]); - if (!$stmt->fetch()) { + $row = $stmt->fetch(); + if (!$row) { // User deleted — destroy session $_SESSION = []; session_destroy(); json_out(['valid' => false]); } + if ($row['blocked'] === true || $row['blocked'] === 't') { + // User blocked — 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]); diff --git a/comments.php b/comments.php index 0a7321b..8379e01 100644 --- a/comments.php +++ b/comments.php @@ -84,6 +84,8 @@ function db_connect(): PDO { function start_session(): void { if (session_status() === PHP_SESSION_NONE) { + ini_set('session.gc_maxlifetime', (string) REMEMBER_COOKIE_TTL); + ini_set('session.save_path', SESSION_SAVE_PATH); session_name(SESSION_NAME); session_set_cookie_params([ 'lifetime' => 0, @@ -171,6 +173,7 @@ function send_notification(string $type, string $actor, string $body, string $po function logged_in_user(): ?array { if (empty($_SESSION['user_id']) || empty($_SESSION['username'])) return null; + // Verify the user still exists in the DB (handles deleted accounts / wiped DB) try { $pdo = db_connect(); diff --git a/full-setup.sh b/full-setup.sh index 43ef648..3c24cbb 100755 --- a/full-setup.sh +++ b/full-setup.sh @@ -21,12 +21,15 @@ DB_USER="" DB_PASS="" PROSODY_DB_USER="" PROSODY_DB_PASS="" # must match /etc/prosody/prosody.cfg.lua -DOMAIN="" +DOMAIN="backend.freedoms4.org" CERTBOT_EMAIL="" -API_DIR="" -ENV_FILE="" +API_DIR="/var/www/freedoms4/api" +ENV_FILE="/etc/freedoms4/auth.env" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -OTP_FROM="" # for example: no-reply@freedoms4.org +OTP_FROM="no-reply@freedoms4.org" +SESSION_SAVE_PATH="/var/lib/php/sessions-freedoms4" +REMEMBER_COOKIE_TTL=34560000 # 400 days — "Keep me logged in" cookie lifetime + # Must run as root if [[ $EUID -ne 0 ]]; then @@ -204,6 +207,12 @@ if [[ ! -S /run/php/php8.2-fpm.sock ]]; then fi success "php8.2-fpm ready." +# Dedicated session save path, kept outside phpsessionclean's default sweep target +mkdir -p "${SESSION_SAVE_PATH}" +chown www-data:www-data "${SESSION_SAVE_PATH}" +chmod 700 "${SESSION_SAVE_PATH}" +success "Session save path ready at ${SESSION_SAVE_PATH}." + # ── STEP 5 Mail server check ── info "Checking mail server (Postfix) for OTP delivery..." @@ -365,6 +374,8 @@ fi mkdir -p "${API_DIR}" cp "${SCRIPT_DIR}/auth.php" "${API_DIR}/auth.php" +sed -i "/define('SESSION_SAMESITE',/a define('SESSION_SAVE_PATH', '${SESSION_SAVE_PATH}');" "${API_DIR}/auth.php" +sed -i "/define('SESSION_SAMESITE',/a define('REMEMBER_COOKIE_TTL', ${REMEMBER_COOKIE_TTL});" "${API_DIR}/auth.php" chown -R www-data:www-data "${API_DIR}" chmod 640 "${API_DIR}/auth.php" success "auth.php deployed." @@ -373,6 +384,8 @@ if [[ ! -f "${SCRIPT_DIR}/comments.php" ]]; then error "comments.php not found in ${SCRIPT_DIR}." fi cp "${SCRIPT_DIR}/comments.php" "${API_DIR}/comments.php" +sed -i "/define('SESSION_SAMESITE',/a define('SESSION_SAVE_PATH', '${SESSION_SAVE_PATH}');" "${API_DIR}/comments.php" +sed -i "/define('SESSION_SAMESITE',/a define('REMEMBER_COOKIE_TTL', ${REMEMBER_COOKIE_TTL});" "${API_DIR}/comments.php" chown www-data:www-data "${API_DIR}/comments.php" chmod 640 "${API_DIR}/comments.php" success "comments.php deployed." @@ -381,6 +394,8 @@ if [[ ! -f "${SCRIPT_DIR}/admin.php" ]]; then error "admin.php not found in ${SCRIPT_DIR}." fi cp "${SCRIPT_DIR}/admin.php" "${API_DIR}/admin.php" +sed -i "/define('SESSION_SAMESITE',/a define('SESSION_SAVE_PATH', '${SESSION_SAVE_PATH}');" "${API_DIR}/admin.php" +sed -i "/define('SESSION_SAMESITE',/a define('REMEMBER_COOKIE_TTL', ${REMEMBER_COOKIE_TTL});" "${API_DIR}/admin.php" chown www-data:www-data "${API_DIR}/admin.php" chmod 640 "${API_DIR}/admin.php" success "admin.php deployed." diff --git a/rollback-everything.sh b/rollback-everything.sh index 916f01f..1a1127e 100755 --- a/rollback-everything.sh +++ b/rollback-everything.sh @@ -45,11 +45,12 @@ info "Dropping freedoms4 database and user..." (cd /tmp && sudo -u postgres psql -c "DROP USER IF EXISTS freedoms4_user;") success "Database and user dropped." -# ── 5. Remove deployed API dir and env file ── -info "Removing API dir and env file..." +# ── 5. Remove deployed API dir, env file, and session save path ── +info "Removing API dir, env file, and session save path..." rm -rf /var/www/freedoms4 rm -rf /etc/freedoms4 -success "API dir and env file removed." +rm -rf /var/lib/php/sessions-freedoms4 +success "API dir, env file, and session save path removed." # ── 6. Remove nginx site ── info "Removing nginx site config..." diff --git a/uninstall.sh b/uninstall.sh index c7274c9..86de0dd 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -31,11 +31,12 @@ echo "" info "Stopping php8.2-fpm..." systemctl stop php8.2-fpm -# ── 2. Remove deployed API dir and env file ── -info "Removing API dir and env file..." +# ── 2. Remove deployed API dir, env file, and session save path ── +info "Removing API dir, env file, and session save path..." rm -rf /var/www/freedoms4 rm -rf /etc/freedoms4 -success "API dir and env file removed." +rm -rf /var/lib/php/sessions-freedoms4 +success "API dir, env file, and session save path removed." # ── 3. Remove nginx site ── info "Removing nginx site config..." @@ -82,7 +83,7 @@ echo " - Dovecot auth-passwdfile config (clients can still log in)" echo " - Postfix SASL + virtual transport config (mail still sends/receives)" echo "" echo " Removed:" -echo " - API dir (/var/www/freedoms4), env file (/etc/freedoms4)" +echo " - API dir (/var/www/freedoms4), env file (/etc/freedoms4), session save path (/var/lib/php/sessions-freedoms4)" echo " - Nginx site for backend.freedoms4.org" echo " - email-account-create wrapper + sudoers rule (no new accounts via signup)" echo ""