commit 25186c3aa9edea069ff5f253151f779977e2876f Author: Andrew Stowell Date: Wed Nov 12 13:25:59 2025 -0500 initial commit diff --git a/500.php b/500.php new file mode 100644 index 0000000..460f57a --- /dev/null +++ b/500.php @@ -0,0 +1,54 @@ + + + + + + <?php echo $env->getTitle(); ?> + + + + + + + + + + + + + + + +

getTitle(); ?>

+

Internal Server Error

+

Review the logs for more details.

+ + diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..54ce9f2 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,64 @@ +# allow the pre-auth page to work +preauth.example.com { + reverse_proxy preauth:9000 { + header_up X-Forwarded-Uri {uri} + header_down -X-Powered-By + rewrite /preauth.php + transport fastcgi { + root /preauth/ + split .php + } + } +} + +# if using caddy v2.9.x+ you can use this snippet +# snippet to put the pre-auth system in front any service easily +(preauth) { + reverse_proxy {args[0]} 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 + } + {block} + } + } +} + +# snippet usage: this will cause pre-auth to restrict access to https://protected.example.com/secure +protected.example.com { + import preauth /secure { + reverse_proxy protected-service:9000 + } + 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 +#} + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..caf68f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM php:8.4-fpm-alpine + +# get compose so we can install our php dependencies +COPY --from=composer /usr/bin/composer /usr/bin/composer + +# app is stored here +RUN mkdir /preauth +WORKDIR /preauth +COPY . /preauth/ + +# sessions are stored here +RUN mkdir -p /tmp/sessions + +# fcgi command for the healthcheck +RUN apk add fcgi + +# install our php dependencies +RUN composer install + +EXPOSE 9000 + +HEALTHCHECK --interval=60s --retries=3 --start-interval=1s --start-period=10s --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 + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7b11552 --- /dev/null +++ b/composer.json @@ -0,0 +1,12 @@ +{ + "autoload": { + "psr-4": { + "Preauth\\": "src/" + } + }, + "require": { + "spomky-labs/otphp": "^11.3", + "symfony/http-foundation": "^7.3", + "symfony/uid": "^7.3" + } +} diff --git a/example.env b/example.env new file mode 100644 index 0000000..a749928 --- /dev/null +++ b/example.env @@ -0,0 +1,17 @@ +# 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, text or colors +# how long a session lasts (in minutes): 43200 is 30 days +PREAUTH_TTL=43200 +PREAUTH_SUBDOMAIN='preauth' +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' + diff --git a/health.php b/health.php new file mode 100644 index 0000000..e6bc554 --- /dev/null +++ b/health.php @@ -0,0 +1,4 @@ +getSecret(); + error_log("ERROR: PREAUTH_TOKEN is not set, generating random TOTP token:\n'$token'\nupdate your config or it will be regenerated when you restart."); +} + +echo $token; + diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..efa3b65 --- /dev/null +++ b/init.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# ensure we have required settings +if [ -z "$PREAUTH_KEY" ]; then + export PREAUTH_KEY=$(cd /preauth && php init-key.php) +fi + +if [ -z "$PREAUTH_TOKEN" ]; then + export PREAUTH_TOKEN=$(cd /preauth && php init-token.php) +fi + +exec php-fpm + diff --git a/preauth.php b/preauth.php new file mode 100644 index 0000000..23edd28 --- /dev/null +++ b/preauth.php @@ -0,0 +1,15 @@ +run(); + +/* no additional output most of the time, sometimes we'll continue to display the login screen */ +if ($auth->showTemplate()) { + include __DIR__ . '/template.php'; +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..166167e --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,241 @@ + ['com','net','off','org'], + 'am' => ['radio'], + 'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za'], + 'de' => ['com'], + 'fm' => ['radio'], + 'gg' => ['co','net','org'], + 'in' => ['co','firm','gen','ind','net','org'], + 'je' => ['co','net','org'], + 'mx' => ['com','net','org'], + 'net' => ['gb','hu','in','jp','se','uk'], + 'nz' => ['co','net','org'], + 'org' => ['ae','us'], + 'ph' => ['com','net','org'], + 'se' => ['com'], + 'uk' => ['co','me','org'], + ]; + + /** @var string $preauth subdomain (without domain) the login page will use */ + private string $preauth; + /** @var string $domain domain (without subdomains) we are controlling auth for */ + private string $domain; + /** @var int $expire time-to-live of auth cookie */ + private int $expire; + /** @var array $get like $_GET, but based on data given by reverse proxy */ + private array $get = []; + /** @var string $key secret for encryption to store sessions */ + private string $key; + /** @var string $token secret for 2FA token */ + private string $token; + /** @var bool $stop set to false to print login page */ + private bool $stop = true; + /** @var string $id user provided name for their session */ + private string $id; + + /** + * @param string $host domain which auth is relative to + */ + public function __construct(string $host) { + $this->preauth = getenv('PREAUTH_SUBDOMAIN') ?: 'preauth'; + $this->domain = $this->baseDomain($host); + $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); + } + + /** + * @return boolean returns true if we should display login page, false if we are done + */ + public function showTemplate(): bool { + return ( ! $this->stop); + } + + /** + * @return string returns the return-to-url, if one was specified, empty string otherwise + */ + public function getReturnTo(): string { + $rt = $this->get['rt'] ?? ''; + if ( ! is_string($rt)) { + $rt = ''; + } + return $rt; + } + + /** + * @return string returns our current domain with all subdomains removed + */ + public function getBaseDomain(): string { + return $this->domain; + } + + /** + * Entry point of code, review request and determine course of action + */ + public function run(): void { + if ( ! $this->key || ! $this->token) { + $this->die(); + } + + /* already logged in with valid session, return 200, so caddy permits request */ + $uuid = $this->getExistingUUID(); + if ($uuid) { + echo "ok $this->id"; + return; + } + /* if request is valid login attempt, (set cookie and) return to where they came from */ + if ($this->login()) { + if (isset($this->get['rt'])) { + header("Location: {$this->get['rt']}"); + } + echo "ok $this->id"; + return; + } + + /* not already logged in, not trying to login, but on auth page, so present login screen */ + 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( + ($_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"); + } + } + + /** + * This lets us determine the base domain: + * "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com" + * @param string $host domain with zero or more subdomains + * @return string returns same domain with all subdomains removed + */ + private function baseDomain(string $host): string { + $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)) { + $keep = 3; + } + $parts = array_reverse(array_slice($parts, 0, $keep)); + return implode('.', $parts); + } + + /** + * Stop, critical server config issue + */ + private function die(): string { + error_log('PREAUTH_KEY and/or PREAUTH_TOKEN are not set, unable to continue.'); + header('http/1.1 500 Internal Server Error', true, 500); + include __DIR__ . '/../500.php'; + exit(0); + } + + /** + * @return string|null returns the existing UUID if there is one and it is valid, 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 '$', where the filename is the encoded-uuid */ + if ($encUUID && is_file(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, '+/', '-_')); + $rawIV = base64_decode($iv); + $uuid = openssl_decrypt($rawUUID, self::CIPHER, $this->key, 0, $rawIV); + if (Uuid::isValid($uuid) && (int)$expire >= time()) { + $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 */ + unlink(self::BASE . $encUUID); + } + } + return null; + } + + /** + * @return string returns a newly generated random uuid + */ + private function newUUID(): string { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + /** + * Upon valid token and non-empty id, creates a uuid, stores session file and sets the cookie + * @return bool returns true if token and id are provided, and token is valid, false otherwise + */ + private function login(): bool { + /* fields must both be filled out */ + if (isset($this->get['token'], $this->get['id']) && $this->get['token'] && $this->get['id']) { + /* filter user input */ + $id = substr(preg_replace(self::URL64, '', $this->get['id']), 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']) { + $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"); + setcookie( + self::NAME, + $encUUID, + $this->expire, + '/', /* all paths */ + $this->domain, /* all subdomains */ + true, /* https only */ + true /* no js access */ + ); + error_log("[$date] successful login by id: $id"); + return true; + } + /* log failed logins, so we can fail2ban bad actors */ + error_log("[$date] failed login attempted by ip: $remoteHost"); + } + return false; + } +} + diff --git a/src/Env.php b/src/Env.php new file mode 100644 index 0000000..ccd513c --- /dev/null +++ b/src/Env.php @@ -0,0 +1,49 @@ + + + + + + <?php echo $env->getTitle(); ?> + + + + + + + + + + + + + + + +

getTitle(); ?>

+
+ +
+
+
+
+
+
+ +