initial commit

This commit is contained in:
2025-11-12 15:15:30 -05:00
commit 25186c3aa9
13 changed files with 600 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
<?php
use Preauth\Env;
global $auth;
$env = new Env();
?>
<!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>Internal Server Error</h2>
<p>Review the logs for more details.</p>
</body>
</html>
+64
View File
@@ -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
#}
+27
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
{
"autoload": {
"psr-4": {
"Preauth\\": "src/"
}
},
"require": {
"spomky-labs/otphp": "^11.3",
"symfony/http-foundation": "^7.3",
"symfony/uid": "^7.3"
}
}
+17
View File
@@ -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'
+4
View File
@@ -0,0 +1,4 @@
<?php
/* ensure php is processing */
echo implode('', ['o', 'n', 'l', 'i', 'n', 'e', "\n"]);
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
$key = getenv('PREAUTH_KEY');
// generate a new key, if not specified
if ( ! $key) {
$key = base64_encode(random_bytes(64));
error_log("ERROR: PREAUTH_KEY is not set, generating a random session encryption key:\n'$key'\nupdate your config or it will be regenerated when you restart.");
}
echo $key;
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use OTPHP\TOTP;
$token = getenv('PREAUTH_TOKEN');
// generate a new token, if not specified
if ( ! $token) {
$token = TOTP::generate()->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;
Executable
+13
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Preauth\Auth;
/* kickoff the script */
$auth = new Auth($_SERVER['HTTP_HOST'] ?? 'example.com');
$auth->run();
/* no additional output most of the time, sometimes we'll continue to display the login screen */
if ($auth->showTemplate()) {
include __DIR__ . '/template.php';
}
+241
View File
@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace Preauth;
use OTPHP\TOTP;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\Uid\Uuid;
class Auth {
/* the encryption cipher we are using */
private const CIPHER = 'camellia-256-ctr';
/* only allow A-z 0-9 _ - */
private const URL64 = '/[^A-Za-z0-9_-]+/';
/* cookie name */
private const NAME = '_auth_uuid';
/* directory to store sessions in */
private const BASE = '/tmp/sessions/';
/* top-level-domains which are known to have multiple parts */
private const TLD = [
'ai' => ['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 '<iv-b64>$<session-name>', 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;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Preauth;
class Env {
/**
* @return string returns title of this system
*/
public function getTitle(): string {
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
}
/**
* @return string returns text-color of this system
*/
public function getTextColor(): string {
return getenv('PREAUTH_FOREGROUND') ?: '#ffffff'; // white
}
/**
* @return string returns the name of the ID field
*/
public function getIdName(): string {
return getenv('PREAUTH_ID_NAME') ?: 'Session ID';
}
/**
* @return string returns the name of the Token field
*/
public function getTokenName(): string {
return getenv('PREAUTH_TOKEN_NAME') ?: 'Authentication Token';
}
/**
* @return string returns the name of the Submit button
*/
public function getSubmitName(): string {
return getenv('PREAUTH_SUBMIT_NAME') ?: 'Submit';
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
use Preauth\Env;
global $auth;
$env = new Env();
?>
<!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;
width: 100%;
}
body {
display: table-cell;
vertical-align: middle;
}
h1 {
font-size: 2.5em;
font-weight: normal;
text-align: center;
}
form {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
form div {
width: 45%;
}
div.right {
text-align: right;
}
div.center {
text-align: center;
}
</style>
</head>
<body>
<h1><?php echo $env->getTitle(); ?></h1>
<form action="/" method="get">
<input type="hidden" name="rt" 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 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 class="center"><button type="submit"><?php echo $env->getSubmitName(); ?></button></div>
</form>
</body>
</html>