Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c235aad941 | ||
|
|
b61400085a | ||
|
|
4a543f45ca | ||
|
|
4cec5a5963 | ||
|
|
c3fbb12842 |
@@ -3,6 +3,7 @@ twig:
|
||||
strict_variables: true
|
||||
globals:
|
||||
env:
|
||||
allow_password: '%env(STATIC_SECRET_ENABLED)%'
|
||||
title: '%env(TITLE)%'
|
||||
bg_color: '%env(BG_COLOR)%'
|
||||
fg_color: '%env(FG_COLOR)%'
|
||||
@@ -10,8 +11,10 @@ twig:
|
||||
return_field: '%env(QUERY_PREFIX)%return'
|
||||
id_field: '%env(QUERY_PREFIX)%id'
|
||||
token_field: '%env(QUERY_PREFIX)%token'
|
||||
password_field: '%env(QUERY_PREFIX)%password'
|
||||
id_name: '%env(ID_NAME)%'
|
||||
token_name: '%env(TOKEN_NAME)%'
|
||||
password_name: '%env(PASSWORD_NAME)%'
|
||||
submit_name: '%env(SUBMIT_NAME)%'
|
||||
error_message: '%env(ERROR_MESSAGE)%'
|
||||
teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
|
||||
@@ -26,12 +26,12 @@ parameters:
|
||||
# --- extra variables ---
|
||||
# query parameter prefix to prevent collisions
|
||||
env(QUERY_PREFIX): '_preauth_'
|
||||
# allow files in /app/public/assets directory to be served, false to disable
|
||||
env(ASSETS): '1' # default enabled, boolean
|
||||
# how long do we allow all traffic from an ip address after successful login
|
||||
# could be useful if you have a system which does not handle cookies
|
||||
env(IP_TTL): '0' # default disabled, time in seconds
|
||||
# if desired, in addition to supporting a TOTP, you can set a static password
|
||||
# TODO rely on checking enabled, instead of the secret directly throughout the code
|
||||
env(STATIC_SECRET_ENABLED): '0' # boolean
|
||||
env(STATIC_SECRET): '' # default disabled
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
env(TEAPOT): '1' # boolean
|
||||
@@ -43,6 +43,7 @@ parameters:
|
||||
env(ERROR_COLOR): '#ffb16d'
|
||||
env(ID_NAME): 'Session ID'
|
||||
env(TOKEN_NAME): 'Authentication Token'
|
||||
env(PASSWORD_NAME): 'Authentication Password'
|
||||
env(SUBMIT_NAME): 'Submit'
|
||||
env(ERROR_MESSAGE): 'Unsuccessful login attempt'
|
||||
# title and message to use on block page, if teapot is true
|
||||
@@ -59,8 +60,8 @@ parameters:
|
||||
app.query_prefix: '%env(QUERY_PREFIX)%'
|
||||
app.totp_uri: '%env(TOTP_URI)%'
|
||||
|
||||
app.assets: '%env(ASSETS)%'
|
||||
app.ip_ttl: '%env(IP_TTL)%'
|
||||
app.static_secret_enabled: '%env(STATIC_SECRET_ENABLED)%'
|
||||
app.static_secret: '%env(STATIC_SECRET)%'
|
||||
app.teapot: '%env(TEAPOT)%'
|
||||
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@
|
||||
#IP_TTL=0 # default disabled, time in seconds
|
||||
|
||||
# if desired, in addition to supporting a TOTP, you can set a static password
|
||||
#STATIC_SECRET='' # deafult disabled
|
||||
#STATIC_SECRET_ENABLED='0' # boolean, disabled by default
|
||||
#STATIC_SECRET='' # default disabled
|
||||
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
#TEAPOT=true # default enabled, boolean
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
# Preauth
|
||||
Pre-authorization, because sometimes you need both a belt and suspenders.
|
||||
For when you want to expose a web service without letting the whole world try to access it. Because sometimes you want both a belt and suspenders.
|
||||
|
||||
The goal of this is to make it as simple as possible to put a web service behind an extra layer of authentication.
|
||||
I found myself needing to make my personal Nextcloud instance available outside my VPN, but was worried since it has had authentication exploits in the past.
|
||||
|
||||
Maybe you need the extra protection because it's a very sensitive system, or because it's a legacy system with known security issues.
|
||||
So, I built a simple authentication gateway, which eventually turned into this project.
|
||||
|
||||
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
|
||||
|
||||
## Requirements
|
||||
|
||||
* Docker
|
||||
* Caddy (as a reverse proxy)
|
||||
* a domain
|
||||
* 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.
|
||||
|
||||
There is an example Caddyfile in /docs/ and example .env file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
|
||||
There is an example Caddyfile in /docs/ and env.example file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
|
||||
|
||||
When someone tries to reach your protected web service, Caddy will check with preauth if they are allowed, if their preauth cookie is missing, invalid, or expired, we will show them to a login screen.
|
||||
|
||||
I say login, but it's really just a TOTP code (6 digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service.
|
||||
|
||||
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.
|
||||
|
||||
**TODO**
|
||||
when user gives bad cookie, remove it
|
||||
I say login, but it's really just a TOTP code (6 digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
|
||||
|
||||
First time you spin up the docker container it will generate a TOTP secret (which you'll load into your authenticator app); or generate you own.
|
||||
|
||||
Be sure to save that TOTP secret to your docker environment, so that it persistents beyond removing the container.
|
||||
|
||||
### History
|
||||
#### v0.4.1 (Dec 26th, 2025)
|
||||
Fixed bug which can occur if you delete cache files.
|
||||
|
||||
#### v0.4.0 (Dec 26th, 2025)
|
||||
Massive rewrite to switch to using listeners instead of controller, header for login payload instead of get request, removed icon system, asset system, was able to remove all the domain processing, enhanced cookie security, and more.
|
||||
|
||||
#### v0.3.0 (Dec 15th, 2025)
|
||||
Includes significant breaking changes.
|
||||
Default port and transportation changed to http via port 80.
|
||||
Names of environment variables have changed.
|
||||
|
||||
#### v0.2.0 (Dec 3rd, 2025)
|
||||
Now with login rate limiting.
|
||||
New page for client error (too many requests).
|
||||
Made example docker compose.
|
||||
|
||||
#### v0.1.0 (Nov 14th, 2025)
|
||||
Now an actual project, docker image pushed to docker hub, which uses php-fpm, code into a src folder, templates into separate files.
|
||||
|
||||
#### v0.0.1 (June 26th, 2024)
|
||||
Started off as a single file script which was part of my caddy config. Hardcoded TOTP secret, zero flexibility, but functional. Would stay like that, quietly working in production for about a full year before any real change.
|
||||
|
||||
+2
-9
@@ -15,7 +15,6 @@ final readonly class ConfigBag {
|
||||
private int $limitTtl;
|
||||
private string $queryPrefix;
|
||||
private string $totpUri;
|
||||
private ?string $assetsDir;
|
||||
private ?int $ipTtl;
|
||||
private ?string $staticSecret;
|
||||
private bool $teapot;
|
||||
@@ -33,9 +32,8 @@ final readonly class ConfigBag {
|
||||
#[Autowire('%app.limit_ttl%')] int $limitTtl,
|
||||
#[Autowire('%app.query_prefix%')] string $queryPrefix,
|
||||
#[Autowire('%app.totp_uri%')] string $totpUri,
|
||||
#[Autowire('%app.assets%')] bool $assets,
|
||||
#[Autowire('%kernel.project_dir%/public/assets/')] string $assetsDir,
|
||||
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
||||
#[Autowire('%app.static_secret_enabled%')] bool $staticSecretEnabled,
|
||||
#[Autowire('%app.static_secret%')] ?string $staticSecret,
|
||||
#[Autowire('%app.teapot%')] bool $teapot,
|
||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||
@@ -49,9 +47,8 @@ final readonly class ConfigBag {
|
||||
$this->limitTtl = ($limitTtl >= 1) ? $limitTtl : 86400;
|
||||
$this->queryPrefix = $queryPrefix;
|
||||
$this->totpUri = $totpUri ?: $utilities->loadTotp();
|
||||
$this->assetsDir = $assets ? $assetsDir : null;
|
||||
$this->ipTtl = $ipTtl ?: null;
|
||||
$this->staticSecret = $staticSecret ?: null;
|
||||
$this->staticSecret = $staticSecretEnabled ? ($staticSecret ?: null) : null;
|
||||
$this->teapot = $teapot;
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->teapotTitle = $teapotTitle;
|
||||
@@ -86,10 +83,6 @@ final readonly class ConfigBag {
|
||||
return $this->totpUri;
|
||||
}
|
||||
|
||||
public function assetsDir(): ?string {
|
||||
return $this->assetsDir;
|
||||
}
|
||||
|
||||
public function ipTtl(): ?int {
|
||||
return $this->ipTtl;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ final readonly class InterceptListener {
|
||||
* nor submitting login credentials; so present the login page now */
|
||||
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
||||
$content = $this->twig->render('login.html.twig', [
|
||||
'nonce_value' => $this->makeNonce(),
|
||||
'nonce' => $this->makeNonce(),
|
||||
]);
|
||||
$event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED,
|
||||
['Content-Type' => 'text/html']
|
||||
|
||||
@@ -98,7 +98,7 @@ final readonly class LoginListener {
|
||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(60); /* keep for 1 minute */
|
||||
$nonceItem->expiresAfter(static::NONCE_TTL); /* keep breifly */
|
||||
$this->noncePool->save($nonceItem);
|
||||
|
||||
/* token authentication successful, grant access and set response */
|
||||
@@ -159,7 +159,7 @@ final readonly class LoginListener {
|
||||
if (($nonceItem->isHit() && $nonceItem->get()) || ! $nonceItem->isHit()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(60); /* keep for 1 minute */
|
||||
$nonceItem->expiresAfter(static::NONCE_TTL); /* keep breifly */
|
||||
$this->noncePool->save($nonceItem);
|
||||
|
||||
/* password authentication successful, grant access and set response */
|
||||
@@ -239,19 +239,17 @@ final readonly class LoginListener {
|
||||
$status = Response::HTTP_UNAUTHORIZED;
|
||||
$message = $this->config->errorMessage();
|
||||
}
|
||||
$answer = [
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
];
|
||||
|
||||
if ($json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode([
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
]);
|
||||
$content = json_encode($answer);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = $this->twig->render('login.html.twig', [
|
||||
'error_message' => $message,
|
||||
'nonce_value' => $this->makeNonce(),
|
||||
]);
|
||||
$content = $this->twig->render('login.html.twig', $answer);
|
||||
}
|
||||
|
||||
return new Response($content, $status, ["Content-Type" => $contentType]);
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
trait MakeNonceTrait {
|
||||
/* 15 bytes neatly fits in base64 */
|
||||
private const NONCE_LENGTH = 15;
|
||||
private const NONCE_TTL = 60;
|
||||
private const NONCE_TTL = 120;
|
||||
|
||||
protected readonly CacheItemPoolInterface $noncePool;
|
||||
protected readonly LoggerInterface $logger;
|
||||
|
||||
@@ -5,8 +5,13 @@ html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display:
|
||||
body { display: table-cell; vertical-align: middle; }
|
||||
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
|
||||
p { color: {{ env.error_color }}; text-align: center; }
|
||||
form { display: flex; flex-wrap: wrap; justify-content: center; }
|
||||
form div { width: 45%; }
|
||||
div.right { text-align: right; }
|
||||
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
|
||||
form div { width: 45%; min-width: 300px; }
|
||||
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
|
||||
div.center { text-align: center; }
|
||||
div.hidden { display: none; }
|
||||
span { cursor: pointer; font-size: 0.75em; text-decoration: underline; }
|
||||
button { background-color: #cccccc; }
|
||||
input { background-color: #ffffff; max-width: 100%; }
|
||||
button, input { border: 0.0625em solid #333333; border-radius: 0.25em; color: #333333; font-size: 0.9em; }
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ env.title }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
{{ include('_style.html.twig') }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+69
-12
@@ -2,31 +2,88 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ env.title }}</h1>
|
||||
<p id="preauth-message">{{ error_message|default }}</p>
|
||||
<p id="preauth-message">{{ message|default }}</p>
|
||||
<form id="preauth-form">
|
||||
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce_value }}">
|
||||
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce }}">
|
||||
<div class="right"><label for="preauth-id">{{ env.id_name }}:</label></div>
|
||||
<div><input type="text" name="preauth_id" id="preauth-id"
|
||||
autocomplete="username" required="required" autofocus="autofocus"></div>
|
||||
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div>
|
||||
<div><input type="text" name="preauth_token" id="preauth-token"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
{% if env.allow_password %}
|
||||
<div class="right boxTk">
|
||||
<label for="preauth-token">{{ env.token_name }}:</label><br>
|
||||
<span id="preauth-use-pw">🔃 {{ env.password_name }}</span></div>
|
||||
<div class="boxTk"><input type="text" name="preauth_token" id="preauth-token"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
|
||||
<div class="right boxPw hidden">
|
||||
<label for="preauth-password">{{ env.password_name }}:</label><br>
|
||||
<span id="preauth-use-tk">🔃 {{ env.token_name }}</span></div>
|
||||
<div class="boxPw hidden"><input type="password" name="preauth_password" id="preauth-password"
|
||||
autocomplete="password" disabled="disabled" required="required"></div>
|
||||
{% else %}
|
||||
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div>
|
||||
<div><input type="text" name="preauth_token" id="preauth-token"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
{% endif %}
|
||||
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
|
||||
</form>
|
||||
<script>
|
||||
const form = document.getElementById('preauth-form');
|
||||
const message = document.getElementById('preauth-message');
|
||||
|
||||
{% if env.allow_password %}
|
||||
const usePw = document.getElementById('preauth-use-pw');
|
||||
const useTk = document.getElementById('preauth-use-tk');
|
||||
const boxPw = document.querySelectorAll('.boxPw');
|
||||
const boxTk = document.querySelectorAll('.boxTk');
|
||||
|
||||
usePw.addEventListener('click', (event) => {
|
||||
form.preauth_token.value = '';
|
||||
form.preauth_token.disabled = true;
|
||||
form.preauth_password.disabled = false;
|
||||
boxTk.forEach((element) => {
|
||||
element.classList.add('hidden');
|
||||
});
|
||||
boxPw.forEach((element) => {
|
||||
element.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
useTk.addEventListener('click', (event) => {
|
||||
form.preauth_password.value = '';
|
||||
form.preauth_password.disabled = true;
|
||||
form.preauth_token.disabled = false;
|
||||
boxPw.forEach((element) => {
|
||||
element.classList.add('hidden');
|
||||
});
|
||||
boxTk.forEach((element) => {
|
||||
element.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
/* make bas64url string containing our payload json object */
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.preauth_id.value,
|
||||
token: form.preauth_token.value,
|
||||
nonce: form.preauth_nonce.value,
|
||||
json: true
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
{% if env.allow_password %}
|
||||
/* make base64url string containing our payload json object */
|
||||
/* payload will contain either token or password */
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.preauth_id.value,
|
||||
...( form.preauth_token.value && { token: form.preauth_token.value }),
|
||||
...(( ! form.preauth_token.value) && { password: form.preauth_password.value }),
|
||||
nonce: form.preauth_nonce.value,
|
||||
json: true
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
{% else %}
|
||||
/* make base64url string containing our payload json object */
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.preauth_id.value,
|
||||
token: form.preauth_token.value,
|
||||
nonce: form.preauth_nonce.value,
|
||||
json: true
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
{% endif %}
|
||||
|
||||
/* send our request to the server */
|
||||
fetch(window.location.href, {
|
||||
|
||||
Reference in New Issue
Block a user