299 lines
10 KiB
PHP
299 lines
10 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;
|
|
|
|
// No other site can show the pages in a frame, where a hidden page can take an editor's click
|
|
header('X-Frame-Options: DENY');
|
|
header("Content-Security-Policy: frame-ancestors 'none'");
|
|
|
|
if (isset($_SERVER['HTTPS'])) {
|
|
// Browsers use HTTPS for this host for a year, without a plain request first
|
|
header('Strict-Transport-Security: max-age=31536000');
|
|
session_cache_limiter('private');
|
|
// Refuse a session id that PHP did not make, so nobody can plant one
|
|
ini_set('session.use_strict_mode', '1');
|
|
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;
|
|
}
|
|
|
|
/*
|
|
* TRUE if $password matches $stored_hash. For an unknown editor
|
|
* ($stored_hash is NULL), it hashes the password anyway, so that the
|
|
* answer takes as long as for a wrong password, and does not show which
|
|
* usernames exist.
|
|
*/
|
|
function editor_password_valid($password, $stored_hash) {
|
|
if ($stored_hash === NULL) {
|
|
password_hash($password, password_algorithm());
|
|
return FALSE;
|
|
}
|
|
// password_verify() also accepts hashes made by the old crypt() call
|
|
return password_verify($password, $stored_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
|
|
}
|