tentatively added rate limiting, block by IP if too many failed login attempts

This commit is contained in:
2025-12-02 22:16:56 -05:00
parent 3bb2d9cecd
commit f60a81e259
8 changed files with 183 additions and 58 deletions
+3
View File
@@ -0,0 +1,3 @@
[Caddyfile]
indent_style = tab
+12 -1
View File
@@ -15,9 +15,20 @@ PREAUTH_TITLE='Pre-Authentication System'
PREAUTH_ID_NAME='Session ID'
PREAUTH_TOKEN_NAME='Authentication Token'
PREAUTH_SUBMIT_NAME='Submit'
PREAUTH_DENIED_CODE='418'
# how many consecutive failed login attempts before we block them (a remote-ip)
PREAUTH_RATE_LIMIT=4
# maximum time between failed login attempts to still be consecutive (in minutes): 360 is 6 hours
PREAUTH_RATE_TIMEOUT=360
# how long after last failed login will they be blocked (in minutes): 1440 is 24 hours
PREAUTH_RATE_BLOCKED=1440
# what do we show when they get rate-limited
PREAUTH_DENIED_CODE=418
PREAUTH_DENIED_TITLE="I'm a teapot"
PREAUTH_DENIED_MESSAGE='I refuse to brew coffee.'
# alternatively, you could use a more standard response
#PREAUTH_DENIED_CODE=429
#PREAUTH_DENIED_TITLE='Too Many Requests'
#PREAUTH_DENIED_MESSAGE='Try again later.'
# who owns the session files
# permissions of the volume must match
+3 -24
View File
@@ -15,7 +15,7 @@ preauth.example.com {
# snippet to put the pre-auth system in front any service easily
(preauth) {
reverse_proxy {args[0]} preauth:9000 {
method GET
method GET
header_up X-Forwarded-Uri {uri}
header_down -X-Powered-By
rewrite /preauth.php
@@ -33,7 +33,8 @@ preauth.example.com {
}
}
# snippet usage: this will cause pre-auth to restrict access to https://protected.example.com/secure
# snippet usage: this will cause preauth to restrict access to
# https://protected.example.com/secure
protected.example.com {
import preauth /secure {
reverse_proxy protected-service:9000
@@ -41,25 +42,3 @@ protected.example.com {
reverse_proxy exposed-service:9000
}
## alternatively, if using an older version of caddy,
## you will have to have add the boilerplate in your block
#protected.example.com {
# reverse_proxy /secure preauth:9000 {
# header_up X-Forwarded-Uri {uri}
# header_down -X-Powered-By
# rewrite /preauth.php
# transport fastcgi {
# root /preauth/
# split .php
# }
# @preauth_ok status 2xx
# handle_response @preauth_ok {
# copy_response_headers {
# include Set-Cookie Location
# }
# reverse_proxy protected-service:9000
# }
# }
# reverse_proxy exposed-service:9000
#}
+10 -3
View File
@@ -8,9 +8,16 @@ RUN mkdir /preauth
WORKDIR /preauth
COPY . /preauth/
# sessions are stored here
# add php config for rate limit monitoring
RUN mkdir -p /usr/local/etc/php/conf.d
COPY preauth-php.ini /usr/local/etc/php/conf.d/preauth-php.ini
# login sessions are stored here
RUN mkdir -p /tmp/sessions
# rate limit monitoring information is stored here
RUN mkdir -p /tmp/monitor
# fcgi command for the healthcheck
RUN apk add fcgi
@@ -19,9 +26,9 @@ RUN composer install
EXPOSE 9000
HEALTHCHECK --interval=60s --retries=3 --start-interval=1s --start-period=10s --timeout=5s \
HEALTHCHECK --interval=5m --retries=3 --start-interval=5s --start-period=50s --timeout=5s \
CMD SCRIPT_NAME=/health.php SCRIPT_FILENAME=/preauth/health.php REQUEST_METHOD=GET \
cgi-fcgi -bind -connect localhost:9000 | grep 'online' || exit 1
ENTRYPOINT /preauth/init.sh
ENTRYPOINT ["/preauth/init.sh"]
+11 -2
View File
@@ -1,4 +1,13 @@
<?php
/* ensure php is processing */
echo implode('', ['o', 'n', 'l', 'i', 'n', 'e', "\n"]);
/* do garbage collection on rate limit monitoring sessions */
session_start();
$success = session_gc();
session_destroy();
if ($success !== false) {
/* if garbage collection was successful, report that we are healthy */
echo implode('', ['o', 'n', 'l', 'i', 'n', 'e', "\n"]);
} else {
echo "error\n";
}
+20
View File
@@ -0,0 +1,20 @@
# preauth uses "sessions" to store usage statistics by IP address and is used for rate limiting
# login sessions are stored as file in /tmp/sessions, and are unrelated to this configuration
# sessions are IP based, cookie not needed nor wanted
session.use_cookies = off
session.use_only_cookies = off
session.cache_limiter = ''
session.name = preauth
session.save_path = /tmp/monitor
# seconds until a session may be pruned
# IE: maximum time range to review for rate limiting and
# maximum time an IP address can be blocked
# 86400 seconds (24 hours) hardcoded upper limit
session.gc_maxlifetime = 86400
# disable random random garbage collection
# our healthcheck runs session_gc()
session.gc_probability = 0
+121 -25
View File
@@ -8,6 +8,12 @@ use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\Uid\Uuid;
class Auth {
/* name of the return-to field */
public const RETURN_FIELD = 'preauth_rt';
/* name of the id field */
public const ID_FIELD = 'preauth_id';
/* name of the token field */
public const TOKEN_FIELD = 'preauth_token';
/* the encryption cipher we are using */
private const CIPHER = 'camellia-256-ctr';
/* only allow A-z 0-9 _ - */
@@ -61,7 +67,9 @@ class Auth {
$this->key = base64_decode(getenv('PREAUTH_KEY') ?: '');
$this->token = getenv('PREAUTH_TOKEN') ?: '';
$this->expire = time() + 60 * ((int)getenv('PREAUTH_TTL') ?: 43200);
parse_str((parse_url(($_SERVER['HTTP_X_FORWARDED_URI'] ?? ''), PHP_URL_QUERY) ?: ''), $this->get);
parse_str((parse_url((
$_SERVER['HTTP_X_FORWARDED_URI'] ?? ''
), PHP_URL_QUERY) ?: ''), $this->get);
}
/**
@@ -75,7 +83,7 @@ class Auth {
* @return string returns the return-to-url, if one was specified, empty string otherwise
*/
public function getReturnTo(): string {
$rt = $this->get['rt'] ?? '';
$rt = $this->get[self::RETURN_FIELD] ?? '';
if ( ! is_string($rt)) {
$rt = '';
}
@@ -103,6 +111,13 @@ class Auth {
echo "ok $this->id";
return;
}
/* if too many requests (from the remote-ip), then trigger rate limiting */
if ($this->rateLimit()) {
include __DIR__ . '/../400.php';
exit(0);
}
/* if request is valid login attempt, (set cookie and) return to where they came from */
if ($this->login()) {
if ($this->getReturnTo()) {
@@ -114,12 +129,14 @@ class Auth {
return;
}
/* not already logged in, not trying to login, but on auth page, so present login screen */
/* not already logged in, but on auth page, so present login screen */
/* either not trying to login or had a failed login attempt */
if ($_SERVER['HTTP_HOST'] === "$this->preauth.$this->domain") {
header('http/1.1 401 Unauthorized', true, 401);
$this->stop = false;
} else {
/* not already logged in, not trying to login, and not on auth page, so send to login screen */
/* not already logged in, not trying to login, and not on auth page */
/* so send to login screen */
$rt = rawurlencode(
($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https') . '://' .
($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $this->domain) .
@@ -139,7 +156,9 @@ class Auth {
$parts = array_reverse(explode('.', $host));
$keep = min(2, count($parts));
/* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[0]]) && in_array($parts[1], self::TLD[$parts[0]], true)) {
if (count($parts) > 2 && isset(self::TLD[$parts[0]]) &&
in_array($parts[1], self::TLD[$parts[0]], true)
) {
$keep = 3;
}
$parts = array_reverse(array_slice($parts, 0, $keep));
@@ -157,15 +176,18 @@ class Auth {
}
/**
* @return string|null returns the existing UUID if there is one and it is valid, null otherwise
* @return string|null returns the existing UUID if there is valid one, null otherwise
*/
private function getExistingUUID(): ?string {
/* filter user input */
$encUUID = preg_replace(self::URL64, '', ($_COOKIE[self::NAME] ?? ''));
/* session exists as a file, in the format '<iv-b64>$<session-name>', where the filename is the encoded-uuid */
/* session exists as a file where the filename is the encoded-uuid */
/* in the format '<iv-b64>$<session-name>$<expiration>$<date>$<remote-host>' */
if ($encUUID && is_file(self::BASE . $encUUID)) {
[$iv, $id, $expire] = explode('$', (file_get_contents(self::BASE . $encUUID) ?: '') . '$$');
[$iv, $id, $expire] = explode('$', (file_get_contents(
self::BASE . $encUUID
) ?: '') . '$$');
if ($iv) {
/* raw-uuid means not encoded, but still encrypted */
$rawUUID = base64_decode(strtr($encUUID, '+/', '-_'));
@@ -175,13 +197,51 @@ class Auth {
$this->id = substr(preg_replace(self::URL64, '', $id), 0, 100);
return $uuid;
}
/* we have something encrypted, but it is expired or not valid, so delete the session file */
/* we have something encrypted, but it is expired or invalid, so delete it */
unlink(self::BASE . $encUUID);
}
}
return null;
}
/**
* Check if the remote-host has made too many login requests recently, and block if needed
* @return bool returns true if we should rate-limit this request, false otherwise
*/
private function rateLimit(): bool {
/* our identifier for this remote-host, replace all special characters with dashes */
/* session_id() only allows ",", "-", and alphanumeric characters */
$limiterId = preg_replace('[^a-zA-Z0-9]', '-', $this->getRemoteHost());
session_id($limiterId);
session_start();
/* new remote-ip, start logging */
if ( ! isset($_SESSION['count'], $_SESSION['time'])) {
$this->resetRateLimit(false);
return false;
}
$sessionCount = (int)$_SESSION['count'];
$sessionTime = (int)$_SESSION['time'];
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
$rateTimeout = (int)getenv('PREAUTH_RATE_TIMEOUT') ?: 360;
$rateBlocked = (int)getenv('PREAUTH_RATE_BLOCKED') ?: 1440;
$rateMaximum = max($rateTimeout, $rateBlocked);
if ($sessionCount >= $rateLimit && time() - $rateBlocked <= $sessionTime) {
/* if over limit, and block current, block them */
return true;
} else if ($sessionCount < $rateLimit && time() - $rateTimeout <= $sessionTime) {
/* if under limit, and timeout current, allow them */
return false;
}
/* either over limit and block has expired or */
/* under limit and timeout has expired, so reset them */
$this->resetRateLimit(false);
return false;
}
/**
* @return string returns a newly generated random uuid
*/
@@ -198,30 +258,26 @@ class Auth {
*/
private function login(): bool {
/* fields must both be filled out */
if (isset($this->get['token'], $this->get['id']) && $this->get['token'] && $this->get['id']) {
if (isset($this->get[self::TOKEN_FIELD], $this->get[self::ID_FIELD]) &&
$this->get[self::TOKEN_FIELD] && $this->get[self::ID_FIELD]
) {
/* filter user input */
$id = substr(preg_replace(self::URL64, '', $this->get['id']), 0, 100);
$id = substr(preg_replace(self::URL64, '', $this->get[self::ID_FIELD]), 0, 100);
$otp = TOTP::createFromSecret($this->token);
$date = date('Y-m-d H:i:s');
/* determine real remote-host, if local address, find next level up */
$remoteHost = $_SERVER['REMOTE_HOST'];
if (IpUtils::isPrivateIp($remoteHost)) {
$remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
$remoteIndex = array_search($remoteHost, $remoteList);
if ($remoteIndex > 0) {
$remoteHost = $remoteList[$remoteIndex - 1];
}
}
/* if given token is valid, login, store session file and set the cookie */
if ($otp->now() === $this->get['token']) {
if ($otp->now() === $this->get[self::TOKEN_FIELD]) {
$this->id = $id;
$uuid = $this->newUUID();
$rawIV = random_bytes(openssl_cipher_iv_length(self::CIPHER));
$rawUUID = openssl_encrypt($uuid, self::CIPHER, $this->key, 0, $rawIV);
$encUUID = strtr(base64_encode($rawUUID), '-_', '+/');
file_put_contents(self::BASE . $encUUID, base64_encode($rawIV) . "\$$id\$$this->expire\$$date\$$remoteHost\$\n");
file_put_contents(
self::BASE . $encUUID,
base64_encode($rawIV) .
"\$$id\$$this->expire\$$date\${$this->getRemoteHost()}\$\n"
);
setcookie(
self::NAME,
$encUUID,
@@ -232,12 +288,52 @@ class Auth {
true /* no js access */
);
error_log("[$date] successful login by id: $id");
/* successful login, reset the rate-limit and close */
$this->resetRateLimit();
return true;
}
/* log failed logins, so we can fail2ban bad actors */
error_log("[$date] failed login attempted by ip: $remoteHost");
/* login attempted and failed, update monitoring for rate limiting */
$this->logFailedAttempt();
}
return false;
}
/**
* Determine real remote-host, if local address, find next level up
* @return string returns the real remote host (IP address)
*/
private function getRemoteHost(): string {
$remoteHost = $_SERVER['REMOTE_HOST'];
if (IpUtils::isPrivateIp($remoteHost)) {
$remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
$remoteIndex = array_search($remoteHost, $remoteList);
if ($remoteIndex > 0) {
$remoteHost = $remoteList[$remoteIndex - 1];
}
}
return $remoteHost;
}
/**
* [Re]Initialize the rate-limiting and close monitoring session (unless you pass false)
* @param bool $close Defaults to true, set to false to keep monitoring session open
*/
private function resetRateLimit(bool $close = true): void {
$_SESSION['time'] = time();
$_SESSION['count'] = 0;
if ($close) {
session_write_close();
}
}
/**
* Note that login failed so we can determine if we should rate-limit future requests
*/
private function logFailedAttempt(): void {
$_SESSION['time'] = time();
$_SESSION['count']++;
session_write_close();
}
}
+3 -3
View File
@@ -63,11 +63,11 @@ $env = new Env();
<body>
<h1><?php echo $env->getTitle(); ?></h1>
<form action="/" method="get">
<input type="hidden" name="rt" value="<?php echo $auth->getReturnTo(); ?>">
<input type="hidden" name="<?php echo Preauth::RETURN_FIELD; ?>" value="<?php echo $auth->getReturnTo(); ?>">
<div class="right"><label for="id"><?php echo $env->getIdName(); ?>:</label></div>
<div><input type="text" name="id" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
<div><input type="text" name="<?php echo Preauth::ID_FIELD; ?>" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
<div class="right"><label for="token"><?php echo $env->getTokenName(); ?>:</label></div>
<div><input type="text" name="token" id="token" autocomplete="off" required="required"></div>
<div><input type="text" name="<?php echo Preauth::TOKEN_FIELD; ?>" id="token" autocomplete="off" required="required"></div>
<div class="center"><button type="submit"><?php echo $env->getSubmitName(); ?></button></div>
</form>
</body>