diff --git a/action.php b/action.php index d0eec8a..a6c0268 100644 --- a/action.php +++ b/action.php @@ -3,8 +3,6 @@ /** * Make things happen when buttons are pressed and forms submitted. */ -use LdapTools\LdapManager; -use LdapTools\Object\LdapObjectType; require_once __DIR__ . "/required.php"; @@ -18,6 +16,8 @@ if ($VARS['action'] == 'signout' && $_SESSION['loggedin'] != true) { dieifnotloggedin(); +engageRateLimit(); + require_once __DIR__ . "/lib/login.php"; function returnToSender($msg, $arg = "") { diff --git a/api.php b/api.php index 8e776c7..0c40984 100644 --- a/api.php +++ b/api.php @@ -14,7 +14,8 @@ header("Content-Type: application/json"); //try { $key = $VARS['key']; if ($database->has('apikeys', ['key' => $key]) !== TRUE) { - header("HTTP/1.1 403 Unauthorized"); + engageRateLimit(); + http_response_code(403); insertAuthLog(14, null, "Key: " . $key); die("\"403 Unauthorized\""); } @@ -82,7 +83,7 @@ switch ($VARS['action']) { exit(json_encode(["status" => "ERROR", "msg" => lang("login incorrect", false)])); } } else { - header("HTTP/1.1 400 Bad Request"); + http_response_code(400); die("\"400 Bad Request\""); } break; @@ -118,6 +119,7 @@ switch ($VARS['action']) { case "acctstatus": exit(json_encode(["status" => "OK", "account" => get_account_status($VARS['username'])])); case "login": + engageRateLimit(); // simulate a login, checking account status and alerts $errmsg = ""; if (authenticate_user($VARS['username'], $VARS['password'], $errmsg)) { @@ -195,7 +197,7 @@ switch ($VARS['action']) { exit(json_encode(["status" => "ERROR", "msg" => lang("user does not exist", false)])); } } else { - header("HTTP/1.1 400 Bad Request"); + http_response_code(400); die("\"400 Bad Request\""); } $managed = $database->select('managers', 'employeeid', ['managerid' => $managerid]); @@ -215,7 +217,7 @@ switch ($VARS['action']) { exit(json_encode(["status" => "ERROR", "msg" => lang("user does not exist", false)])); } } else { - header("HTTP/1.1 400 Bad Request"); + http_response_code(400); die("\"400 Bad Request\""); } $managers = $database->select('managers', 'managerid', ['employeeid' => $empid]); @@ -230,7 +232,7 @@ switch ($VARS['action']) { break; case "permission": if (is_empty($VARS['code'])) { - header("HTTP/1.1 400 Bad Request"); + http_response_code(400); die("\"400 Bad Request\""); } $perm = $VARS['code']; @@ -247,15 +249,15 @@ switch ($VARS['action']) { exit(json_encode(["status" => "ERROR", "msg" => lang("user does not exist", false)])); } } else { - header("HTTP/1.1 400 Bad Request"); + http_response_code(400); die("\"400 Bad Request\""); } $hasperm = account_has_permission($user, $perm); exit(json_encode(["status" => "OK", "has_permission" => $hasperm])); break; default: - header("HTTP/1.1 400 Bad Request"); - die("\"400 Bad Request\""); + http_response_code(404); + die(json_encode(["status" => "ERROR", "msg" => "The requested action is not available."])); } /* } catch (Exception $e) { header("HTTP/1.1 500 Internal Server Error"); diff --git a/database.mwb b/database.mwb index e7161c3..2ab560f 100644 Binary files a/database.mwb and b/database.mwb differ diff --git a/index.php b/index.php index 920b982..e3db8c8 100644 --- a/index.php +++ b/index.php @@ -18,6 +18,7 @@ $username_ok = false; $multiauth = false; $change_password = false; if ($VARS['progress'] == "1") { + engageRateLimit(); if (!RECAPTCHA_ENABLED || (RECAPTCHA_ENABLED && verifyReCaptcha($VARS['g-recaptcha-response']))) { $autherror = ""; if (user_exists($VARS['username'])) { @@ -81,6 +82,7 @@ if ($VARS['progress'] == "1") { insertAuthLog(8, null, "Username: " . $VARS['username']); } } else if ($VARS['progress'] == "2") { + engageRateLimit(); if ($_SESSION['passok'] !== true) { // stop logins using only username and authcode sendError("Password integrity check failed!"); @@ -95,6 +97,7 @@ if ($VARS['progress'] == "1") { insertAuthLog(6, null, "Username: " . $VARS['username']); } } else if ($VARS['progress'] == "chpasswd") { + engageRateLimit(); if (!is_empty($_SESSION['username'])) { $error = []; $result = change_password($VARS['oldpass'], $VARS['newpass'], $VARS['conpass'], $error); diff --git a/lib/login.php b/lib/login.php index b21f23e..0bb539b 100644 --- a/lib/login.php +++ b/lib/login.php @@ -288,24 +288,7 @@ function sendLoginAlertEmail($username) { function insertAuthLog($type, $uid = null, $data = "") { global $database; // find IP address - $ip = ""; - if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) { - $ip = $_SERVER["HTTP_CF_CONNECTING_IP"]; - } else if (isset($_SERVER["HTTP_CLIENT_IP"])) { - $ip = $_SERVER["HTTP_CLIENT_IP"]; - } else if (isset($_SERVER["HTTP_X_FORWARDED_FOR"])) { - $ip = $_SERVER["HTTP_X_FORWARDED_FOR"]; - } else if (isset($_SERVER["HTTP_X_FORWARDED"])) { - $ip = $_SERVER["HTTP_X_FORWARDED"]; - } else if (isset($_SERVER["HTTP_FORWARDED_FOR"])) { - $ip = $_SERVER["HTTP_FORWARDED_FOR"]; - } else if (isset($_SERVER["HTTP_FORWARDED"])) { - $ip = $_SERVER["HTTP_FORWARDED"]; - } else if (isset($_SERVER["REMOTE_ADDR"])) { - $ip = $_SERVER["REMOTE_ADDR"]; - } else { - $ip = "NOT FOUND"; - } + $ip = getClientIP(); $database->insert("authlog", ['logtime' => date("Y-m-d H:i:s"), 'logtype' => $type, 'uid' => $uid, 'ip' => $ip, 'otherdata' => $data]); } diff --git a/mobile/index.php b/mobile/index.php index d8bc1ad..54c8174 100644 --- a/mobile/index.php +++ b/mobile/index.php @@ -28,6 +28,7 @@ if (is_empty($VARS['username']) || is_empty($VARS['key'])) { // Make sure the username and key are actually legit $user_key_valid = $database->has('mobile_codes', ['[>]accounts' => ['uid' => 'uid']], ["AND" => ['mobile_codes.code' => $VARS['key'], 'accounts.username' => $VARS['username']]]); if ($user_key_valid !== TRUE) { + engageRateLimit(); http_response_code(401); insertAuthLog(21, null, "Username: " . $VARS['username'] . ", Key: " . $VARS['key']); die(json_encode(["status" => "ERROR", "msg" => "Invalid username and/or access key."])); @@ -40,6 +41,7 @@ switch ($VARS['action']) { // If we get this far, it is, so return success. exit(json_encode(["status" => "OK"])); case "check_password": + engageRateLimit(); if (get_account_status($VARS['username']) != "NORMAL") { insertAuthLog(20, null, "Username: " . $VARS['username'] . ", Key: " . $VARS['key']); exit(json_encode(["status" => "ERROR", "msg" => lang("login failed try on web", false)])); diff --git a/required.php b/required.php index 9d7944f..bb542b8 100644 --- a/required.php +++ b/required.php @@ -241,3 +241,152 @@ function redirectIfNotLoggedIn() { die(); } } + +/** + * Check if a given ipv4 address is in a given cidr + * @param string $ip IP to check in IPV4 format eg. 127.0.0.1 + * @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed + * @return boolean true if the ip is in this range / false if not. + * @author Thorsten Ott + */ +function ip4_in_cidr($ip, $cidr) { + if (strpos($cidr, '/') == false) { + $cidr .= '/32'; + } + // $range is in IP/CIDR format eg 127.0.0.1/24 + list( $cidr, $netmask ) = explode('/', $cidr, 2); + $range_decimal = ip2long($cidr); + $ip_decimal = ip2long($ip); + $wildcard_decimal = pow(2, ( 32 - $netmask)) - 1; + $netmask_decimal = ~ $wildcard_decimal; + return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) ); +} + +/** + * Check if a given ipv6 address is in a given cidr + * @param string $ip IP to check in IPV6 format + * @param string $cidr CIDR netmask + * @return boolean true if the IP is in this range, false otherwise. + * @author MW. + */ +function ip6_in_cidr($ip, $cidr) { + $address = inet_pton($ip); + $subnetAddress = inet_pton(explode("/", $cidr)[0]); + $subnetMask = explode("/", $cidr)[1]; + + $addr = str_repeat("f", $subnetMask / 4); + switch ($subnetMask % 4) { + case 0: + break; + case 1: + $addr .= "8"; + break; + case 2: + $addr .= "c"; + break; + case 3: + $addr .= "e"; + break; + } + $addr = str_pad($addr, 32, '0'); + $addr = pack("H*", $addr); + + $binMask = $addr; + return ($address & $binMask) == $subnetAddress; +} + +/** + * Check if the REMOTE_ADDR is on Cloudflare's network. + * @return boolean true if it is, otherwise false + */ +function validateCloudflare() { + if (filter_var($_SERVER["REMOTE_ADDR"], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + // Using IPv6 + $cloudflare_ips_v6 = [ + "2400:cb00::/32", + "2405:8100::/32", + "2405:b500::/32", + "2606:4700::/32", + "2803:f800::/32", + "2c0f:f248::/32", + "2a06:98c0::/29" + ]; + $valid = false; + foreach ($cloudflare_ips_v6 as $cidr) { + if (ip6_in_cidr($_SERVER["REMOTE_ADDR"], $cidr)) { + $valid = true; + break; + } + } + } else { + // Using IPv4 + $cloudflare_ips_v4 = [ + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "104.16.0.0/12", + "108.162.192.0/18", + "131.0.72.0/22", + "141.101.64.0/18", + "162.158.0.0/15", + "172.64.0.0/13", + "173.245.48.0/20", + "188.114.96.0/20", + "190.93.240.0/20", + "197.234.240.0/22", + "198.41.128.0/17" + ]; + $valid = false; + foreach ($cloudflare_ips_v4 as $cidr) { + if (ip4_in_cidr($_SERVER["REMOTE_ADDR"], $cidr)) { + $valid = true; + break; + } + } + } + return $valid; +} + +/** + * Makes a good guess at the client's real IP address. + * + * @return string Client IP or `0.0.0.0` if we can't find anything + */ +function getClientIP() { + // If CloudFlare is in the mix, we should use it. + // Check if the request is actually from CloudFlare before trusting it. + if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) { + if (validateCloudflare()) { + return $_SERVER["HTTP_CF_CONNECTING_IP"]; + } + } + + if (isset($_SERVER["REMOTE_ADDR"])) { + return $_SERVER["REMOTE_ADDR"]; + } + + return "0.0.0.0"; // This will not happen unless we aren't a web server +} + +/** + * Check if the client's IP has been doing too many brute-force-friendly + * requests lately. + * Kills the script with a "friendly" error and response code 429 + * (Too Many Requests) if the last access time in the DB is too near. + * + * Also updates the rate_limit table with the latest data and purges old rows. + * @global type $database + */ +function engageRateLimit() { + global $database; + $delay = date("Y-m-d H:i:s", strtotime("-2 seconds")); + $database->delete('rate_limit', ["lastaction[<]" => $delay]); + if ($database->has('rate_limit', ["AND" => ["ipaddr" => getClientIP()]])) { + http_response_code(429); + // JSONify it so API clients don't scream too loud + die(json_encode(["status" => "ERROR", "msg" => "You're going too fast. Slow down, mkay?"])); + } else { + // Add a record for the IP address + $database->insert('rate_limit', ["ipaddr" => getClientIP(), "lastaction" => date("Y-m-d H:i:s")]); + } +}