0, 'path' => '/', 'domain' => cookie_domain(), 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); session_start(); // An editor whose account was removed is logged out if (isset($_SESSION['editor']) && !editor_exists($_SESSION['editor'])) unset($_SESSION['editor']); if (isset($_SESSION['editor'])) { $is_admin = TRUE; $username = $_SESSION['editor']; } elseif (check_persistent_login()) { // Note that check_persistent_login() sets $_SESSION['editor'] $is_admin = TRUE; $username = $_SESSION['editor']; } else { $is_admin = FALSE; } // Don't cache if $is_admin is set header( 'Cache-Control: no-store, no-cache, must-revalidate' ); header( 'Cache-Control: post-check=0, pre-check=0', false ); header( 'Pragma: no-cache' ); } /* * Persistent logins: the cookie holds "username|key", and the database * holds only a SHA-256 of the key, so a copy of the table cannot be used * to log in. Each device has its own row. A key works once, for a year * after it was made, and only while the editor account exists. */ function persistent_cookie() { $cookie_data = $_COOKIE['PersistentAuth'] ?? ''; if (!is_string($cookie_data)) $cookie_data = ''; return explode("|", $cookie_data, 2) + array('', ''); } function persistent_key_hash($key) { return hash('sha256', $key); } function check_persistent_login() { if (!array_key_exists('PersistentAuth', $_COOKIE)) { return false; } global $db, $is_admin; $table = "persistent_sessions"; list($username, $key) = persistent_cookie(); if ($username === '' || $key === '') return false; $key_hash = persistent_key_hash($key); open_db('open'); $query = "SELECT p.username, p.session FROM $table p JOIN editors e ON e.username = p.username" . " WHERE p.username = ? AND p.session = ? AND p.created > DATE_SUB(now(), INTERVAL 1 YEAR) LIMIT 1"; $getid = $db->execute_query($query, array($username, $key_hash)) or db_fail(); $row = $getid->fetch_assoc(); open_db('close'); if (isset($row) && hash_equals((string)$row['session'], $key_hash)) { // Log in, then swap the used key for a new one login_editor($username); delete_persistent_login(); create_persistent_login($username); return true; } // A wrong, expired or revoked key: remove it and its cookie delete_persistent_login(); return false; } /* * Remove an editor account, with its persistent logins. Its sessions end * at their next request, when editor_exists() fails. */ function remove_editor($username) { global $db; $opened = open_db('open'); $db->execute_query("DELETE FROM persistent_sessions WHERE username = ?", array($username)) or db_fail(); $db->execute_query("DELETE FROM editors WHERE username = ?", array($username)) or db_fail(); if ($opened) open_db('close'); } function create_persistent_login($username) { $validity = strtotime('+1 year'); // Time to keep the session valid global $db, $is_admin; $table = "persistent_sessions"; open_db('open'); $key = bin2hex(random_bytes(32)); // Add the hash of the session key to the database $query = "INSERT INTO $table (username, session) VALUES (?, ?)"; $db->execute_query($query, array($username, persistent_key_hash($key))) or db_fail(); open_db('close'); // Create cookie with key setcookie('PersistentAuth', "$username|$key", array('expires' => $validity, 'path' => '/', 'domain' => cookie_domain(), 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); return true; } function delete_persistent_login() { global $db, $is_admin; $table = "persistent_sessions"; open_db('open'); list($username, $key) = persistent_cookie(); // Remove this session key from the database if ($username !== '' && $key !== '') { $query = "DELETE FROM $table WHERE username = ? AND session = ?"; $db->execute_query($query, array($username, persistent_key_hash($key))) or db_fail(); } // Reap old sessions, and keys stored before they were hashed $query = "DELETE FROM $table " . " WHERE created < DATE_SUB(now(), INTERVAL 1 YEAR)" . " OR CHAR_LENGTH(session) <> 64;"; $db->query($query) or db_fail(); open_db('close'); // Delete cookie setcookie("PersistentAuth", '', array('expires' => strtotime('-1 year'), 'path' => '/', 'domain' => cookie_domain(), 'secure' => TRUE, 'httponly' => TRUE, 'samesite' => 'Lax')); return true; } /* * CSRF protection: every form that changes something carries a random * token from the session, and the handler checks it before acting. */ function csrf_token() { if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); return $_SESSION['csrf_token']; } function csrf_field() { return ''; } function csrf_valid($token = NULL) { if ($token === NULL) $token = $_POST['csrf_token'] ?? ''; return is_string($token) && isset($_SESSION['csrf_token']) && hash_equals((string)$_SESSION['csrf_token'], $token); } /* * TRUE for a path on this site, such as "/article?id=1". A browser sends * a redirect to "//host" or "/\host" to another site, so those are not * local paths. */ function is_local_path($path) { return is_string($path) && preg_match('#^/(?![/\\\\])#', $path) === 1; } /* * Log an editor in under a new session id, so that a session id that * someone planted or saw before the login is of no use afterwards. */ function login_editor($username) { session_regenerate_id(TRUE); unset($_SESSION['csrf_token']); $_SESSION['editor'] = $username; } /* * Password hashing: Argon2id where PHP was built with it, otherwise * bcrypt. Argon2id hashes are about 100 characters long, so the * editors.password column must hold at least that (VARCHAR(255) is best). */ function password_algorithm() { return defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT; } /* * Store $hash as the password of $username, and return TRUE if the database kept * the whole hash. The database must be open. */ function store_password_hash($username, $hash) { global $db; $db->execute_query("UPDATE editors SET password = ? WHERE username = ?", array($hash, $username)); return password_hash_is_stored($username, $hash); } // TRUE if the database holds exactly $hash for $username function password_hash_is_stored($username, $hash) { global $db; $check = $db->execute_query("SELECT password FROM editors WHERE username = ?", array($username)); $row = $check ? $check->fetch_assoc() : NULL; return $row && $row['password'] === $hash; } /* * After a successful login, move an old hash (MD5-crypt, bcrypt) to the * current algorithm. If the column is too short and MySQL cut the new * hash, put the old one back so that the editor can still log in. */ function rehash_password($username, $password, $stored_hash) { if (!password_needs_rehash($stored_hash, password_algorithm())) return; if (!store_password_hash($username, password_hash($password, password_algorithm()))) { store_password_hash($username, $stored_hash); error_log("xxx: could not store a new password hash for $username; widen editors.password"); } } /* * Login throttling. Failed logins are counted in files outside the web * root, per username and address, per address, and per username. Once a * count reaches its limit within LOGIN_WINDOW, further attempts are * refused without checking the password. */ define('LOGIN_WINDOW', 15 * 60); // seconds function login_limits($username) { $username = strtolower($username); $address = $_SERVER['REMOTE_ADDR'] ?? ''; return array( "user-ip:$username|$address" => 5, "ip:$address" => 20, "user:$username" => 50, ); } function login_throttle_file($key) { $dir = sys_get_temp_dir() . '/xxx-login-throttle'; if (!is_dir($dir)) @mkdir($dir, 0700, TRUE); return $dir . '/' . hash('sha256', $key); } // Times of the failed logins for $key within the window function login_failures($key) { $file = login_throttle_file($key); $times = is_file($file) ? json_decode((string)@file_get_contents($file), TRUE) : array(); $since = time() - LOGIN_WINDOW; return array_values(array_filter(is_array($times) ? $times : array(), function ($time) use ($since) { return is_int($time) && $time > $since; })); } // Seconds until $username may try to log in again from this address, or 0 function login_blocked_for($username) { $wait = 0; foreach (login_limits($username) as $key => $limit) { $times = login_failures($key); if (count($times) >= $limit) $wait = max($wait, min($times) + LOGIN_WINDOW - time()); } return $wait; } function record_login_failure($username) { foreach (array_keys(login_limits($username)) as $key) { $times = login_failures($key); $times[] = time(); file_put_contents(login_throttle_file($key), json_encode($times), LOCK_EX); } } function clear_login_failures($username) { $keys = array_keys(login_limits($username)); @unlink(login_throttle_file($keys[0])); // this username from this address }