diff --git a/publish/login.php b/publish/login.php index fddc585..6ff2918 100644 --- a/publish/login.php +++ b/publish/login.php @@ -5,6 +5,7 @@ if ( ! isset($_SERVER['HTTPS'])) { } require_once("../session.lib.php"); $denied = NULL; +$wait_minutes = 0; $is_admin = NULL; if (isset($_SESSION['editor'])) { session_write_close(); @@ -12,6 +13,9 @@ if (isset($_SESSION['editor'])) { return; } else if (isset($_POST['login']) && !csrf_valid()) { $denied = TRUE; +} else if (isset($_POST['login']) && ($wait = login_blocked_for((string)($_POST['username'] ?? ''))) > 0) { + $denied = TRUE; + $wait_minutes = (int)ceil($wait / 60); } else if (isset($_POST['login'])) { include_once('../open_db.lib.php'); $table = "editors"; @@ -31,12 +35,14 @@ if (isset($_SESSION['editor'])) { // password_verify() also accepts hashes made by the old crypt() call if (!password_verify($compare_password, $stored_password)) { $denied = TRUE; + record_login_failure($username); open_db('close'); } else { // Move an old hash to the current algorithm while we have the password rehash_password($username, $compare_password, $stored_password); open_db('close'); login_editor($username); + clear_login_failures($username); // Keep user logged in with cookie, if they request it if (isset($_POST['remember'])) { create_persistent_login($username); @@ -81,7 +87,9 @@ if (isset($_SESSION['editor'])) {

editor login

Too many failed logins. Please try again in $wait_minutes minute".($wait_minutes == 1 ? "" : "s").".

\n"; +else if ($denied) echo "

Access denied. Please try again.

\n"; ?> diff --git a/session.lib.php b/session.lib.php index 639c4d6..72422d2 100644 --- a/session.lib.php +++ b/session.lib.php @@ -185,3 +185,60 @@ function rehash_password($username, $password, $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 +}