A new editor needs a username and a password of at least 12 characters. A persistent login needs an unexpired key and an existing account. Removing an account revokes its persistent logins, and its sessions end.
276 lines
9.3 KiB
PHP
276 lines
9.3 KiB
PHP
<?php
|
|
require_once(realpath(dirname(__FILE__) . '/open_db.lib.php'));
|
|
require_once(__DIR__ . '/html.lib.php');
|
|
require_once(__DIR__ . '/captcha.lib.php');
|
|
|
|
$is_admin = FALSE;
|
|
|
|
if (isset($_SERVER['HTTPS'])) {
|
|
session_cache_limiter('private');
|
|
session_set_cookie_params(array('lifetime' => 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 '<input type="hidden" name="csrf_token" value="'.csrf_token().'" />';
|
|
}
|
|
|
|
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
|
|
}
|