mirror of
https://github.com/hyzendust/freedoms4-backend-public.git
synced 2026-09-20 19:23:05 +02:00
Fix: auto-logout sessions. Add: support for keep me logged in checkbox. Update: hardening changes
This commit is contained in:
@@ -218,6 +218,8 @@ function xmpp_delete_backup(string $username): void {
|
|||||||
|
|
||||||
// ── Session + admin check ──
|
// ── Session + admin check ──
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
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_name(SESSION_NAME);
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 0,
|
'lifetime' => 0,
|
||||||
|
|||||||
85
auth.php
85
auth.php
@@ -37,7 +37,6 @@ define('PROSODY_HOST', $env['PROSODY_HOST'] ?? 'freedoms4.org');
|
|||||||
define('SESSION_NAME', 'f4_session');
|
define('SESSION_NAME', 'f4_session');
|
||||||
define('SESSION_SECURE', true);
|
define('SESSION_SECURE', true);
|
||||||
define('SESSION_SAMESITE', 'None');
|
define('SESSION_SAMESITE', 'None');
|
||||||
define('SESSION_TTL', 86400); // 24 hours
|
|
||||||
|
|
||||||
define('OTP_FROM', 'no-reply@freedoms4.org');
|
define('OTP_FROM', 'no-reply@freedoms4.org');
|
||||||
define('OTP_TTL', 600); // 10 minutes
|
define('OTP_TTL', 600); // 10 minutes
|
||||||
@@ -79,6 +78,8 @@ function json_out(array $data, int $status = 200): never {
|
|||||||
|
|
||||||
function start_session(): void {
|
function start_session(): void {
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
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_name(SESSION_NAME);
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 0,
|
'lifetime' => 0,
|
||||||
@@ -121,31 +122,6 @@ function prosody_db_connect(): PDO {
|
|||||||
return $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)
|
// OTP failure tracking via APCu (per-IP lockout after OTP_MAX_FAILS attempts)
|
||||||
function otp_fail_count(string $ip): int {
|
function otp_fail_count(string $ip): int {
|
||||||
$key = 'otpfail_' . hash('sha256', $ip);
|
$key = 'otpfail_' . hash('sha256', $ip);
|
||||||
@@ -295,41 +271,12 @@ if (!is_array($body)) {
|
|||||||
}
|
}
|
||||||
$action = $body['action'] ?? '';
|
$action = $body['action'] ?? '';
|
||||||
|
|
||||||
// ── Session + rate limiting ──
|
// ── Session ──
|
||||||
start_session();
|
start_session();
|
||||||
$now = time();
|
$now = time();
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
$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
|
// Send OTP
|
||||||
@@ -406,6 +353,7 @@ if ($action === 'send_otp') {
|
|||||||
if ($action === 'login') {
|
if ($action === 'login') {
|
||||||
$username = trim($body['username'] ?? '');
|
$username = trim($body['username'] ?? '');
|
||||||
$password = $body['password'] ?? '';
|
$password = $body['password'] ?? '';
|
||||||
|
$remember = !empty($body['remember']);
|
||||||
|
|
||||||
if ($username === '' || $password === '') {
|
if ($username === '' || $password === '') {
|
||||||
json_out(['success' => false, 'message' => 'Username and password are required.']);
|
json_out(['success' => false, 'message' => 'Username and password are required.']);
|
||||||
@@ -439,7 +387,17 @@ if ($action === 'login') {
|
|||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['user_id'] = $user['id'];
|
$_SESSION['user_id'] = $user['id'];
|
||||||
$_SESSION['username'] = $user['username'];
|
$_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);
|
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512);
|
||||||
$session_hash = hash('sha256', session_id());
|
$session_hash = hash('sha256', session_id());
|
||||||
@@ -600,17 +558,24 @@ if ($action === 'check_session') {
|
|||||||
json_out(['valid' => false]);
|
json_out(['valid' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user still exists in DB
|
// Verify user still exists in DB and isn't blocked
|
||||||
try {
|
try {
|
||||||
$pdo = db_connect();
|
$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']]);
|
$stmt->execute([':id' => $_SESSION['user_id']]);
|
||||||
if (!$stmt->fetch()) {
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) {
|
||||||
// User deleted — destroy session
|
// User deleted — destroy session
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
session_destroy();
|
session_destroy();
|
||||||
json_out(['valid' => false]);
|
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) {
|
} catch (Exception $e) {
|
||||||
// DB unavailable — don't force logout, just report invalid so frontend can retry
|
// DB unavailable — don't force logout, just report invalid so frontend can retry
|
||||||
json_out(['valid' => false, 'db_error' => true]);
|
json_out(['valid' => false, 'db_error' => true]);
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ function db_connect(): PDO {
|
|||||||
|
|
||||||
function start_session(): void {
|
function start_session(): void {
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
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_name(SESSION_NAME);
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 0,
|
'lifetime' => 0,
|
||||||
@@ -171,6 +173,7 @@ function send_notification(string $type, string $actor, string $body, string $po
|
|||||||
|
|
||||||
function logged_in_user(): ?array {
|
function logged_in_user(): ?array {
|
||||||
if (empty($_SESSION['user_id']) || empty($_SESSION['username'])) return null;
|
if (empty($_SESSION['user_id']) || empty($_SESSION['username'])) return null;
|
||||||
|
|
||||||
// Verify the user still exists in the DB (handles deleted accounts / wiped DB)
|
// Verify the user still exists in the DB (handles deleted accounts / wiped DB)
|
||||||
try {
|
try {
|
||||||
$pdo = db_connect();
|
$pdo = db_connect();
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ DB_USER=""
|
|||||||
DB_PASS=""
|
DB_PASS=""
|
||||||
PROSODY_DB_USER=""
|
PROSODY_DB_USER=""
|
||||||
PROSODY_DB_PASS="" # must match /etc/prosody/prosody.cfg.lua
|
PROSODY_DB_PASS="" # must match /etc/prosody/prosody.cfg.lua
|
||||||
DOMAIN=""
|
DOMAIN="backend.freedoms4.org"
|
||||||
CERTBOT_EMAIL=""
|
CERTBOT_EMAIL=""
|
||||||
API_DIR=""
|
API_DIR="/var/www/freedoms4/api"
|
||||||
ENV_FILE=""
|
ENV_FILE="/etc/freedoms4/auth.env"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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
|
# Must run as root
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
@@ -204,6 +207,12 @@ if [[ ! -S /run/php/php8.2-fpm.sock ]]; then
|
|||||||
fi
|
fi
|
||||||
success "php8.2-fpm ready."
|
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 ──
|
# ── STEP 5 Mail server check ──
|
||||||
info "Checking mail server (Postfix) for OTP delivery..."
|
info "Checking mail server (Postfix) for OTP delivery..."
|
||||||
|
|
||||||
@@ -365,6 +374,8 @@ fi
|
|||||||
|
|
||||||
mkdir -p "${API_DIR}"
|
mkdir -p "${API_DIR}"
|
||||||
cp "${SCRIPT_DIR}/auth.php" "${API_DIR}/auth.php"
|
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}"
|
chown -R www-data:www-data "${API_DIR}"
|
||||||
chmod 640 "${API_DIR}/auth.php"
|
chmod 640 "${API_DIR}/auth.php"
|
||||||
success "auth.php deployed."
|
success "auth.php deployed."
|
||||||
@@ -373,6 +384,8 @@ if [[ ! -f "${SCRIPT_DIR}/comments.php" ]]; then
|
|||||||
error "comments.php not found in ${SCRIPT_DIR}."
|
error "comments.php not found in ${SCRIPT_DIR}."
|
||||||
fi
|
fi
|
||||||
cp "${SCRIPT_DIR}/comments.php" "${API_DIR}/comments.php"
|
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"
|
chown www-data:www-data "${API_DIR}/comments.php"
|
||||||
chmod 640 "${API_DIR}/comments.php"
|
chmod 640 "${API_DIR}/comments.php"
|
||||||
success "comments.php deployed."
|
success "comments.php deployed."
|
||||||
@@ -381,6 +394,8 @@ if [[ ! -f "${SCRIPT_DIR}/admin.php" ]]; then
|
|||||||
error "admin.php not found in ${SCRIPT_DIR}."
|
error "admin.php not found in ${SCRIPT_DIR}."
|
||||||
fi
|
fi
|
||||||
cp "${SCRIPT_DIR}/admin.php" "${API_DIR}/admin.php"
|
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"
|
chown www-data:www-data "${API_DIR}/admin.php"
|
||||||
chmod 640 "${API_DIR}/admin.php"
|
chmod 640 "${API_DIR}/admin.php"
|
||||||
success "admin.php deployed."
|
success "admin.php deployed."
|
||||||
|
|||||||
@@ -45,11 +45,12 @@ info "Dropping freedoms4 database and user..."
|
|||||||
(cd /tmp && sudo -u postgres psql -c "DROP USER IF EXISTS freedoms4_user;")
|
(cd /tmp && sudo -u postgres psql -c "DROP USER IF EXISTS freedoms4_user;")
|
||||||
success "Database and user dropped."
|
success "Database and user dropped."
|
||||||
|
|
||||||
# ── 5. Remove deployed API dir and env file ──
|
# ── 5. Remove deployed API dir, env file, and session save path ──
|
||||||
info "Removing API dir and env file..."
|
info "Removing API dir, env file, and session save path..."
|
||||||
rm -rf /var/www/freedoms4
|
rm -rf /var/www/freedoms4
|
||||||
rm -rf /etc/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 ──
|
# ── 6. Remove nginx site ──
|
||||||
info "Removing nginx site config..."
|
info "Removing nginx site config..."
|
||||||
|
|||||||
@@ -31,11 +31,12 @@ echo ""
|
|||||||
info "Stopping php8.2-fpm..."
|
info "Stopping php8.2-fpm..."
|
||||||
systemctl stop php8.2-fpm
|
systemctl stop php8.2-fpm
|
||||||
|
|
||||||
# ── 2. Remove deployed API dir and env file ──
|
# ── 2. Remove deployed API dir, env file, and session save path ──
|
||||||
info "Removing API dir and env file..."
|
info "Removing API dir, env file, and session save path..."
|
||||||
rm -rf /var/www/freedoms4
|
rm -rf /var/www/freedoms4
|
||||||
rm -rf /etc/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 ──
|
# ── 3. Remove nginx site ──
|
||||||
info "Removing nginx site config..."
|
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 " - Postfix SASL + virtual transport config (mail still sends/receives)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Removed:"
|
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 " - Nginx site for backend.freedoms4.org"
|
||||||
echo " - email-account-create wrapper + sudoers rule (no new accounts via signup)"
|
echo " - email-account-create wrapper + sudoers rule (no new accounts via signup)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user