$expires, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', 'secure' => is_https(), ]; } // Logins used to lapse constantly for two reasons, both fixed here: the // session cookie had lifetime 0, so it died whenever the browser closed (a // phone backgrounding a tab is enough), and PHP sweeps idle session data // after gc_maxlifetime — 24 minutes by default, and on shared hosting another // site's garbage collection can take this app's session files with it. // The remember-me cookie below is the real backstop: even if the server-side // session is swept, the next request silently re-establishes it. ini_set('session.gc_maxlifetime', (string)(REMEMBER_DAYS * 86400)); ini_set('session.use_strict_mode', '1'); // refuse session ids the server never issued session_set_cookie_params([ 'lifetime' => REMEMBER_DAYS * 86400, // session_set_cookie_params takes a duration, not an expiry stamp 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', 'secure' => is_https(), ]); session_name('AUTOFEEDSESS'); session_start(); function is_logged_in(): bool { return !empty($_SESSION['auth']); } // Start an authenticated session. Regenerating the id on every login is what // stops a session id captured before login from being valid after it. function log_in(): void { session_regenerate_id(true); $_SESSION['auth'] = true; } // Issue a "stay logged in" cookie using the selector/validator pattern: the // cookie carries a public selector plus a secret validator, and only a hash // of the validator is stored. A leaked database therefore cannot be turned // back into a login, and the selector keeps the lookup a single indexed query // rather than a scan comparing every row. function remember_login(): void { $selector = bin2hex(random_bytes(9)); $validator = bin2hex(random_bytes(32)); db()->prepare("INSERT INTO auth_tokens (selector, validator, last_used_at) VALUES (?,?,datetime('now'))") ->execute([$selector, hash('sha256', $validator)]); setcookie(REMEMBER_COOKIE, $selector . ':' . $validator, cookie_opts(time() + REMEMBER_DAYS * 86400)); } // Re-establish a session from the remember-me cookie. Runs on any request // that arrives without one, which is exactly the case that used to bounce // you to the login form. function try_remember_login(): void { $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? ''); if ($raw === '' || substr_count($raw, ':') !== 1) { return; } [$selector, $validator] = explode(':', $raw, 2); $st = db()->prepare('SELECT * FROM auth_tokens WHERE selector = ?'); $st->execute([$selector]); $row = $st->fetch(); // hash_equals so a real selector with a wrong validator cannot be told // apart, by timing, from a selector that was never issued. if (!$row || !hash_equals((string)$row['validator'], hash('sha256', $validator))) { return; } if (strtotime($row['created_at'] . ' UTC') < time() - REMEMBER_DAYS * 86400) { db()->prepare('DELETE FROM auth_tokens WHERE id = ?')->execute([$row['id']]); return; } log_in(); db()->prepare("UPDATE auth_tokens SET last_used_at = datetime('now') WHERE id = ?")->execute([$row['id']]); } // Tear a login down completely: server-side session data, the remember-me // row, and both cookies in the browser. Dropping the row matters most — a // token left behind would stay valid for anyone still holding the cookie. function log_out(): void { $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? ''); if ($raw !== '' && substr_count($raw, ':') === 1) { db()->prepare('DELETE FROM auth_tokens WHERE selector = ?')->execute([explode(':', $raw, 2)[0]]); } $_SESSION = []; $kill = cookie_opts(time() - 42000); setcookie(REMEMBER_COOKIE, '', $kill); if (ini_get('session.use_cookies')) { setcookie(session_name(), '', $kill); } unset($_COOKIE[REMEMBER_COOKIE], $_COOKIE[session_name()]); session_destroy(); } // A logged-out request carrying a remember-me cookie is the common case on a // phone that closed the tab days ago, so the check runs before anything asks // whether we are logged in. No cookie means no query, so public visitors and // the logged-out login page cost nothing. if (!is_logged_in()) { try_remember_login(); } function require_login(): void { if (!is_logged_in()) { header('Location: login.php'); exit; } } function csrf_token(): string { return $_SESSION['csrf'] ??= bin2hex(random_bytes(16)); } function csrf_field(): string { return ''; } function check_csrf(): void { if (!hash_equals(csrf_token(), (string)($_POST['csrf'] ?? ''))) { http_response_code(400); exit('Bad request (missing or invalid CSRF token).'); } } function password_is_set(): bool { return is_file(config()['pass_file']); } function verify_password(string $pass): bool { $hash = trim((string)@file_get_contents(config()['pass_file'])); return $hash !== '' && password_verify($pass, $hash); } function save_password(string $pass): void { $file = config()['pass_file']; $dir = dirname($file); if (!is_dir($dir)) { mkdir($dir, 0775, true); } file_put_contents($file, password_hash($pass, PASSWORD_DEFAULT)); } function flash(?string $msg = null): ?string { if ($msg !== null) { $_SESSION['flash'] = $msg; return null; } $m = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return $m; }