Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a5d63eb7 | ||
|
|
f60a81e259 | ||
|
|
3bb2d9cecd | ||
|
|
5ebfad604f | ||
|
|
e34645eeaa | ||
|
|
1f5e4847ca | ||
|
|
91bb67da51 |
@@ -0,0 +1,3 @@
|
||||
[Caddyfile]
|
||||
indent_style = tab
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# required, if missing will generate random values
|
||||
# encryption key used to store sessions (static random bytes) in base64
|
||||
PREAUTH_KEY=''
|
||||
# TOTP (RFC 6238) secret/token (static random bytes) in base32
|
||||
PREAUTH_TOKEN=''
|
||||
|
||||
# optional, change time-to-live, subdomain, default-redirect, text or colors
|
||||
# how long a session lasts (in minutes): 43200 is 30 days
|
||||
PREAUTH_TTL=43200
|
||||
PREAUTH_SUBDOMAIN='preauth'
|
||||
PREAUTH_SEND_TO='https://secure.example.com/'
|
||||
PREAUTH_BACKGROUND='#029386'
|
||||
PREAUTH_FOREGROUND='#ffffff'
|
||||
PREAUTH_TITLE='Pre-Authentication System'
|
||||
PREAUTH_ID_NAME='Session ID'
|
||||
PREAUTH_TOKEN_NAME='Authentication Token'
|
||||
PREAUTH_SUBMIT_NAME='Submit'
|
||||
# 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
|
||||
USER_ID=1000
|
||||
GROUP_ID=1000
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
header("http/1.1 {$env->getDeniedCode()} {$env->getDeniedTitle()}", true, $env->getDeniedCode());
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo $env->getTitle(); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Begin Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
|
||||
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
|
||||
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
|
||||
<!-- End Icons -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
}
|
||||
html {
|
||||
background-color: <?php echo $env->getColor(); ?>;
|
||||
color: <?php echo $env->getTextColor(); ?>;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
font-size: 1.5em;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><?php echo $env->getTitle(); ?></h1>
|
||||
<h2><?php echo $env->getDeniedTitle(); ?></h2>
|
||||
<p><?php echo $env->getDeniedMessage(); ?></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -15,6 +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
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
header_down -X-Powered-By
|
||||
rewrite /preauth.php
|
||||
@@ -32,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
|
||||
@@ -40,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
|
||||
#}
|
||||
|
||||
|
||||
+11
-4
@@ -8,8 +8,15 @@ RUN mkdir /preauth
|
||||
WORKDIR /preauth
|
||||
COPY . /preauth/
|
||||
|
||||
# sessions are stored here
|
||||
RUN mkdir -p /tmp/sessions
|
||||
# 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/data/sessions
|
||||
|
||||
# rate limit monitoring information is stored here
|
||||
RUN mkdir -p /tmp/data/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"]
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# TODO rename ".env.example" to just ".env"
|
||||
# edit USER_ID and GROUP_ID if needed
|
||||
# set PREAUTH_KEY and PREAUTH_TOKEN after initial start to persist those settings
|
||||
- .env
|
||||
expose:
|
||||
- 9000
|
||||
image: digitaladapt/preauth
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
user: ${USER_ID}:${GROUP_ID}
|
||||
volumes:
|
||||
- preauth:/tmp/data
|
||||
|
||||
volumes:
|
||||
preauth:
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
# required, if missing will generate random values
|
||||
# encryption key used to store sessions (static random bytes) in base64
|
||||
PREAUTH_KEY=''
|
||||
# TOTP (RFC 6238) secret/token (static random bytes) in base32
|
||||
PREAUTH_TOKEN=''
|
||||
|
||||
# optional, change time-to-live, subdomain, default-redirect, text or colors
|
||||
# how long a session lasts (in minutes): 43200 is 30 days
|
||||
PREAUTH_TTL=43200
|
||||
PREAUTH_SUBDOMAIN='preauth'
|
||||
PREAUTH_SEND_TO='https://secure.example.com/'
|
||||
PREAUTH_BACKGROUND='#029386'
|
||||
PREAUTH_FOREGROUND='#ffffff'
|
||||
PREAUTH_TITLE='Pre-Authentication System'
|
||||
PREAUTH_ID_NAME='Session ID'
|
||||
PREAUTH_TOKEN_NAME='Authentication Token'
|
||||
PREAUTH_SUBMIT_NAME='Submit'
|
||||
|
||||
+11
-2
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ensure we have required folders
|
||||
`mkdir -p /tmp/data/sessions`
|
||||
`mkdir -p /tmp/data/monitor`
|
||||
|
||||
# ensure we have required settings
|
||||
if [ -z "$PREAUTH_KEY" ]; then
|
||||
export PREAUTH_KEY=$(cd /preauth && php init-key.php)
|
||||
@@ -9,5 +13,6 @@ if [ -z "$PREAUTH_TOKEN" ]; then
|
||||
export PREAUTH_TOKEN=$(cd /preauth && php init-token.php)
|
||||
fi
|
||||
|
||||
# start the process, now that setup is done
|
||||
exec php-fpm
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
; preauth uses php sessions to store usage statistics by IP address and is used for rate limiting
|
||||
; login sessions are stored as file in /tmp/data/sessions, and are unrelated to this configuration
|
||||
|
||||
; sessions are IP based, cookie not needed nor wanted
|
||||
session.use_cookies = off
|
||||
; deprecated in php 8.4
|
||||
;session.use_only_cookies = off
|
||||
session.cache_limiter = ''
|
||||
session.name = preauth
|
||||
session.save_path = /tmp/data/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 aka 24 hours hardcoded upper limit
|
||||
session.gc_maxlifetime = 86400
|
||||
|
||||
; disable random random garbage collection
|
||||
; our healthcheck runs session_gc function
|
||||
session.gc_probability = 0
|
||||
|
||||
@@ -10,7 +10,7 @@ Maybe you need the extra protection because it's a very sensitive system, or bec
|
||||
* Docker
|
||||
* Caddy (as a reverse proxy)
|
||||
* a domain
|
||||
* a web service you want to secure
|
||||
* a web service you want to secure
|
||||
|
||||
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
|
||||
|
||||
@@ -24,4 +24,4 @@ I say login, but it's really just a TOTP code (6 digit code which changes every
|
||||
|
||||
First time you spin up the docker container it will generate an encryption key for session storage, and the TOTP secret (which you'll load into your authenticator app).
|
||||
|
||||
Be sure to save those and add them to the containers environment, or it will generate new values every time it restarts.
|
||||
Be sure to save those and add them to the containers environment, or it will generate new values every time it restarts.
|
||||
|
||||
+141
-28
@@ -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 _ - */
|
||||
@@ -15,7 +21,7 @@ class Auth {
|
||||
/* cookie name */
|
||||
private const NAME = '_auth_uuid';
|
||||
/* directory to store sessions in */
|
||||
private const BASE = '/tmp/sessions/';
|
||||
private const BASE = '/tmp/data/sessions/';
|
||||
/* top-level-domains which are known to have multiple parts */
|
||||
private const TLD = [
|
||||
'ai' => ['com','net','off','org'],
|
||||
@@ -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,18 +129,20 @@ 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 */
|
||||
$rt = rawurlencode(
|
||||
/* not already logged in, not trying to login, and not on auth page */
|
||||
/* so send to login screen */
|
||||
$query = self::RETURN_FIELD . '=' . rawurlencode(
|
||||
($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https') . '://' .
|
||||
($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $this->domain) .
|
||||
($_SERVER['HTTP_X_FORWARDED_URI'] ?? '/')
|
||||
);
|
||||
header("Location: https://$this->preauth.$this->domain/?rt=$rt");
|
||||
header("Location: https://$this->preauth.$this->domain/?$query");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,56 @@ 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());
|
||||
//error_log("limiterId: {$limiterId}");
|
||||
session_id($limiterId);
|
||||
session_start();
|
||||
|
||||
/* new remote-ip, start logging */
|
||||
if ( ! isset($_SESSION['count'], $_SESSION['time'])) {
|
||||
$this->resetRateLimit(false);
|
||||
//error_log('limiter new, allow');
|
||||
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 */
|
||||
//error_log("limiter over limit, block ($sessionCount)");
|
||||
return true;
|
||||
} else if ($sessionCount < $rateLimit && time() - $rateTimeout <= $sessionTime) {
|
||||
/* if under limit, and timeout current, allow them */
|
||||
//error_log("limiter under limit, allow ($sessionCount)");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* either over limit and block has expired or */
|
||||
/* under limit and timeout has expired, so reset them */
|
||||
//error_log("limiter expired, reset");
|
||||
$this->resetRateLimit(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns a newly generated random uuid
|
||||
*/
|
||||
@@ -198,30 +263,27 @@ 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];
|
||||
}
|
||||
}
|
||||
$remoteHost = $this->getRemoteHost();
|
||||
|
||||
/* 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\$$remoteHost\$\n"
|
||||
);
|
||||
setcookie(
|
||||
self::NAME,
|
||||
$encUUID,
|
||||
@@ -232,12 +294,63 @@ 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 */
|
||||
$sessionCount = (int)$_SESSION['count'];
|
||||
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
|
||||
$this->logFailedAttempt();
|
||||
|
||||
if (($sessionCount + 1) >= $rateLimit) {
|
||||
/* +1 to count this failure */
|
||||
error_log("[$date] rate-limiting trigger for: $remoteHost");
|
||||
/* just reached the rate limit, block them */
|
||||
include __DIR__ . '/../400.php';
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -8,21 +8,24 @@ class Env {
|
||||
* @return string returns title of this system
|
||||
*/
|
||||
public function getTitle(): string {
|
||||
return getenv('PREAUTH_TITLE') ?: 'Pre-Authentication System';
|
||||
return getenv('PREAUTH_TITLE')
|
||||
?: 'Pre-Authentication System';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns background-color of this system
|
||||
*/
|
||||
public function getColor(): string {
|
||||
return getenv('PREAUTH_BACKGROUND') ?: '#029386'; // teal
|
||||
// defaults to teal
|
||||
return getenv('PREAUTH_BACKGROUND') ?: '#029386';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns text-color of this system
|
||||
*/
|
||||
public function getTextColor(): string {
|
||||
return getenv('PREAUTH_FOREGROUND') ?: '#ffffff'; // white
|
||||
// defaults to white
|
||||
return getenv('PREAUTH_FOREGROUND') ?: '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +39,8 @@ class Env {
|
||||
* @return string returns the name of the Token field
|
||||
*/
|
||||
public function getTokenName(): string {
|
||||
return getenv('PREAUTH_TOKEN_NAME') ?: 'Authentication Token';
|
||||
return getenv('PREAUTH_TOKEN_NAME')
|
||||
?: 'Authentication Token';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,5 +49,28 @@ class Env {
|
||||
public function getSubmitName(): string {
|
||||
return getenv('PREAUTH_SUBMIT_NAME') ?: 'Submit';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied http status code
|
||||
*/
|
||||
public function getDeniedCode(): string {
|
||||
return getenv('PREAUTH_DENIED_CODE') ?: '418';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied response title
|
||||
*/
|
||||
public function getDeniedTitle(): string {
|
||||
return getenv('PREAUTH_DENIED_TITLE')
|
||||
?: "I'm a teapot";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied response message
|
||||
*/
|
||||
public function getDeniedMessage(): string {
|
||||
return getenv('PREAUTH_DENIED_MESSAGE')
|
||||
?: 'I refuse to brew coffee.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
use Preauth\Auth;
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
@@ -63,11 +64,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 Auth::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 Auth::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 Auth::TOKEN_FIELD; ?>" id="token" autocomplete="off" required="required"></div>
|
||||
<div class="center"><button type="submit"><?php echo $env->getSubmitName(); ?></button></div>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user