WIP: nearly done switching to using header instead of url parameters

This commit is contained in:
2025-12-18 19:20:50 -05:00
parent f486ab7481
commit b36aabb8a3
28 changed files with 481 additions and 240 deletions
-16
View File
@@ -1,16 +0,0 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
-13
View File
@@ -1,13 +0,0 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=f88a1074691c40415be4439345b79f69
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
+1 -3
View File
@@ -1,10 +1,8 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
+11 -12
View File
@@ -1,6 +1,13 @@
# use build image, to simplify final image
FROM php:8.4-trixie AS build
# install APCu and composer
RUN pecl install apcu && \
docker-php-ext-enable apcu
COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apt-get update && \
apt-get install unzip
# symfony required environment variables
ENV APP_ENV=prod
ENV APP_DEBUG=0
@@ -17,14 +24,6 @@ COPY ./templates /app/templates
COPY ./composer.json /app/composer.json
COPY ./composer.lock /app/composer.lock
COPY ./symfony.lock /app/symfony.lock
RUN touch /app/.env
# install APCu and composer
RUN pecl install apcu && \
docker-php-ext-enable apcu
COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apt-get update && \
apt-get install unzip
# install application dependencies
RUN composer install --no-dev --optimize-autoloader
@@ -33,6 +32,10 @@ RUN composer dump-env prod --empty
# start creating final image
FROM dunglas/frankenphp:php8.4-trixie
# install APCu
RUN pecl install apcu && \
docker-php-ext-enable apcu
# symfony required environment variables
ENV APP_ENV=prod
ENV APP_DEBUG=0
@@ -46,10 +49,6 @@ COPY --from=build /app /app
COPY ./Caddyfile /etc/frankenphp/Caddyfile
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
# install APCu
RUN pecl install apcu && \
docker-php-ext-enable apcu
# app uses var folder for cache storage
VOLUME ["/app/var"]
Executable
BIN
View File
Binary file not shown.
+2 -5
View File
@@ -1,8 +1,5 @@
#!/bin/sh
# absolute path to our parent folder
project_dir=$(readlink -f "$0" | xargs dirname | xargs dirname)
docker container rm preauth
docker build . -t digtialadapt/preauth:dev
docker run --name preauth \
@@ -11,7 +8,7 @@ docker run --name preauth \
-e APP_SECRET=f88a1074691c40415be4439345b79f69 \
-e APP_SHARE_DIR=var/share \
-e DEFAULT_URI=http://localhost \
-p 80:80 \
-v ./var/share:/app/var/share \
-p 8000:80 \
digtialadapt/preauth:dev
# -v $project_dir/src:/app/src
-1
View File
@@ -12,7 +12,6 @@
"spomky-labs/otphp": "^11.3",
"symfony/cache": "7.4.*",
"symfony/console": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.4.*",
"symfony/mime": "7.4.*",
Generated
+1 -79
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b820c972d2f251ee34778df936684dfd",
"content-hash": "0347dfc31af034e9753433409f0f2a48",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -1072,84 +1072,6 @@
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/dotenv",
"version": "v7.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/dotenv.git",
"reference": "1658a4d34df028f3d93bcdd8e81f04423925a364"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dotenv/zipball/1658a4d34df028f3d93bcdd8e81f04423925a364",
"reference": "1658a4d34df028f3d93bcdd8e81f04423925a364",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"conflict": {
"symfony/console": "<6.4",
"symfony/process": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Dotenv\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Registers environment variables from a .env file",
"homepage": "https://symfony.com",
"keywords": [
"dotenv",
"env",
"environment"
],
"support": {
"source": "https://github.com/symfony/dotenv/tree/v7.4.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-16T10:14:42+00:00"
},
{
"name": "symfony/error-handler",
"version": "v7.4.0",
+3 -1
View File
@@ -1,8 +1,10 @@
framework:
cache:
app: cache.adapter.filesystem
pools:
noncePool:
adapters:
- cache.adapter.apcu
sessionCache:
adapters:
- cache.adapter.apcu
-6
View File
@@ -31,12 +31,8 @@ parameters:
# 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
# url to send people to, if they directly visit the preauth login page
env(SEND_TO): '' # default disabled
# if desired, in addition to supporting a TOTP, you can set a static password
env(STATIC_SECRET): '' # default disabled
# what subdomain to use for preauth login, only applies if using a domain
env(SUBDOMAIN): 'preauth'
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
@@ -66,9 +62,7 @@ parameters:
app.assets: '%env(ASSETS)%'
app.ip_ttl: '%env(IP_TTL)%'
app.send_to: '%env(SEND_TO)%'
app.static_secret: '%env(STATIC_SECRET)%'
app.subdomain: '%env(SUBDOMAIN)%'
app.teapot: '%env(TEAPOT)%'
services:
+5 -12
View File
@@ -1,14 +1,7 @@
# publically serve preauth, for login page
# TODO replace example.com with your domain
preauth.example.com {
reverse_proxy preauth {
header_up X-Forwarded-Uri {uri}
}
}
# if using caddy v2.9.x+ you can use this snippet
# snippet to put the pre-auth system in front any service easily
(preauth) {
# make sure caddy and preauth are on the same network
reverse_proxy {args[0]} preauth {
method GET
header_up X-Forwarded-Uri {uri}
@@ -25,16 +18,16 @@ preauth.example.com {
# example of securing full subdomain
# TODO replace domain and service name
service.example.com {
import preauth / {
reverse_proxy service
import preauth * {
reverse_proxy service_container
}
}
# you can only lock down only select paths
# or any other match criteria, if desired
# https://protected.example.com/secure
# https://protected.example.com/secure/
protected.example.com {
import preauth /secure {
import preauth /secure/* {
reverse_proxy protected-service:9000
}
reverse_proxy exposed-service:9000
-6
View File
@@ -34,15 +34,9 @@
# could be useful if you have a system which does not handle cookies
#IP_TTL=0 # default disabled, time in seconds
# url to send people to, if they directly visit the preauth login page
#SEND_TO='' # default disabled
# if desired, in addition to supporting a TOTP, you can set a static password
#STATIC_SECRET='' # deafult disabled
# what subdomain to use for preauth login, only applies if using a domain
#SUBDOMAIN='preauth'
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
+8 -4
View File
@@ -16,12 +16,16 @@ It may be possible to use some other reverse proxy, but for now, I'm going to st
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.
Preauth will need a subdomain on the same domain as the service it's securing, the default is "preauth", but you can use whatever you want.
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.
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 redirect 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 redirected to the protected service.
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.
### History
#### 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.
-14
View File
@@ -16,9 +16,7 @@ class ConfigBag {
private string $totpUri;
private ?string $assetsDir;
private ?int $ipTtl;
private ?string $sendTo;
private ?string $staticSecret;
private ?string $subdomain;
private bool $teapot;
public function __construct(
@@ -33,9 +31,7 @@ class ConfigBag {
#[Autowire('%app.assets%')] bool $assets,
#[Autowire('%kernel.project_dir%/public/assets/')] string $assetsDir,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.send_to%')] ?string $sendTo,
#[Autowire('%app.static_secret%')] ?string $staticSecret,
#[Autowire('%app.subdomain%')] ?string $subdomain,
#[Autowire('%app.teapot%')] bool $teapot,
) {
$this->clock = $clock;
@@ -47,9 +43,7 @@ class ConfigBag {
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->assetsDir = $assets ? $assetsDir : null;
$this->ipTtl = $ipTtl ?: null;
$this->sendTo = $sendTo ?: null;
$this->staticSecret = $staticSecret ?: null;
$this->subdomain = $subdomain ?: null;
$this->teapot = $teapot;
}
@@ -89,18 +83,10 @@ class ConfigBag {
return $this->ipTtl;
}
public function sendTo(): ?string {
return $this->sendTo ?: null;
}
public function staticSecret(): ?string {
return $this->staticSecret;
}
public function subdomain(): ?string {
return $this->subdomain;
}
public function teapot(): bool {
return $this->teapot;
}
+44 -59
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\ConfigBag;
use App\MonitorCacheKeys;
use App\Trait\MakeNonceTrait;
use App\Utilities;
use OTPHP\Factory;
use Psr\Cache\CacheItemPoolInterface;
@@ -19,10 +20,19 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Ulid;
final class PreauthController extends AbstractController {
use MakeNonceTrait;
public function __construct(
CacheItemPoolInterface $noncePool,
LoggerInterface $logger,
) {
$this->noncePool = $noncePool;
$this->logger = $logger;
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
#[Route(path: '/{path<.+>?}', name: 'preauth', priority: 99)]
public function index(
LoggerInterface $logger,
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $requestCache,
ConfigBag $config,
@@ -33,24 +43,23 @@ final class PreauthController extends AbstractController {
/* check if they sent the preauth cookie, see: AcceptListener */
/* check if they have made too many failed login attempts, see: RejectListener */
// TODO https://symfony.com/doc/current/rate_limiter.html
// install the symfony rate limiter
// also the #[Target('some-name')] attribute is really cool
/* if they have requested a file we have in our asset directory, then serve it,
* but only if we are on the preauth domain, filenames are limited to safe
* characters (we allow alphanumeric and "- _ . /" without "..") */
if ($path && $config->assetsDir()) {
/* host matches preauth, so we are allowed to serve assets */
if ($utilities->buildDomain($config->subdomain(), $request->getHost()) ===
$request->getHost()
) {
$cleanPath = $utilities->cleanPath($config->assetsDir() . $path);
if ($cleanPath && file_exists($cleanPath)) {
$logger->debug("sending static asset: $path");
return $this->file(
$cleanPath,
disposition: ResponseHeaderBag::DISPOSITION_INLINE
);
}
}
}
//if ($path && $config->assetsDir()) {
// $cleanPath = $utilities->cleanPath($config->assetsDir() . $path);
// if ($cleanPath && file_exists($cleanPath)) {
// $this->logger->debug("sending static asset: $path");
// return $this->file(
// $cleanPath,
// disposition: ResponseHeaderBag::DISPOSITION_INLINE
// );
// }
//}
/* we must monitor keys, so we can enable persistence */
$requestCache = new MonitorCacheKeys($requestCache);
@@ -71,7 +80,7 @@ final class PreauthController extends AbstractController {
if ($sessionCache->hasItem($ipKey)) {
/* request sent from ip with valid existing session */
$id = $sessionCache->getItem($ipKey)->get();
$logger->debug("has valid ip-session: $id");
$this->logger->debug("has valid ip-session: $id");
return new Response("hi $id");
}
@@ -86,7 +95,8 @@ final class PreauthController extends AbstractController {
) {
$otp = Factory::loadFromProvisioningUri($config->totpUri(), $config->clock());
/* they gave us a token, but it does not match totp nor static secret */
if ( ! $otp->verify($request->query->get($config->query('token'))) &&
/* leeway: allow the totp that they gave us match +/- 10 seconds */
if ( ! $otp->verify($request->query->get($config->query('token')), leeway: 10) &&
( ! $config->staticSecret() ||
$request->query->get($config->query('token')) !== $config->staticSecret()
)
@@ -103,7 +113,7 @@ final class PreauthController extends AbstractController {
$requestCache->save($failuresItem);
if (count($failures) >= $config->limit()) {
$logger->debug("starting to block now: {$request->getClientIp()}");
$this->logger->debug("starting to block now: {$request->getClientIp()}");
return $this->render('error.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
], new Response(status: $config->teapot()
@@ -111,21 +121,21 @@ final class PreauthController extends AbstractController {
));
}
$logger->debug("login failure: {$request->getClientIp()}");
$this->logger->debug("login failure: {$request->getClientIp()}");
return $this->render('login.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
'return_value' => $request->query->get($config->query('return')) ?:
"{$request->getPathInfo()}{$request->getQueryString()}",
'has_error' => true,
'base_domain' => $utilities->baseDomain($request->getHost()),
'nonce_value' => $this->makeNonce(),
'error_message' => 'Unsuccessful login attempt',
], new Response(status: Response::HTTP_UNAUTHORIZED));
}
/* successful auth with token, store session and set the cookie */
$cleanId = $utilities->makeCacheKey($request->query->get($config->query('id')));
$ulid = new Ulid();
$sessionCookie = $sessionCache->getItem($utilities->makeCacheKey("cookie_$ulid"));
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$logger->error("successful login but ULID collision aborting");
$this->logger->error("successful login but ULID collision aborting");
// TOOD maybe a pretty error page
return new Response('Internal Server Error',
Response::HTTP_INTERNAL_SERVER_ERROR
@@ -147,48 +157,23 @@ final class PreauthController extends AbstractController {
/* successful login, reset rate-limit of their ip */
$requestCache->deleteItem($ipKey);
$redirect = $request->query->get($config->query('return')) ?: $config->sendTo();
if ( ! $redirect) {
/* just logged in with nowhere to go */
$response = new Response("hi $cleanId");
$response->headers->setCookie(Cookie::create(
$config->query('ulid'), $ulid->toString(),
time() + $config->cookieTtl(),
domain: $utilities->baseDomain($request->getHost()), secure: true
));
$logger->debug("successful login with no return set: $cleanId");
return $response;
}
$response = $this->redirect($redirect);
/* just logged in with nowhere to go */
$response = new Response("hi $cleanId");
$response->headers->setCookie(Cookie::create(
$config->query('ulid'), $ulid->toString(),
time() + $config->cookieTtl(),
domain: $utilities->baseDomain($request->getHost()), secure: true
));
$logger->debug("successful login redirecting back: $cleanId");
$this->logger->debug("successful login with no return set: $cleanId");
return $response;
}
/* host matches where we would send them so show the preauth login page */
if ($utilities->buildDomain($config->subdomain(), $request->getHost()) ===
$request->getHost()
) {
$logger->debug("presenting login page: {$request->getClientIp()}");
return $this->render('login.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
'return_value' => $request->query->get($config->query('return')) ?:
"{$request->getPathInfo()}{$request->getQueryString()}",
]);
}
/* needs to be sent to the login page */
$query = http_build_query([
$config->query('return') => $request->getUri(),
]);
$logger->debug("elsewhere sending to login: {$request->getClientIp()}");
$destination = $utilities->buildDomain($config->subdomain(), $request->getHost());
return $this->redirect("https://$destination/?$query");
/* time to show the preauth login page */
$this->logger->debug("presenting login page: {$request->getClientIp()}");
return $this->render('login.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
'nonce_value' => $this->makeNonce(),
], new Response(status: Response::HTTP_UNAUTHORIZED));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\DTO;
use App\Enum\Scope;
/* When scope is None, json will be considered false.
* When using password, scope will be considered None.
* When scope is Ip but ip-access is disabled, scope will be considered Cookie.
* When using password but password is disabled, request will always fail. */
final class Payload {
public string $id; /* session name, identifying who is logging in */
public ?string $token; /* totp, typically six digits */
public ?string $password; /* static secret, alternative to token, if enabled */
public string $nonce; /* random unique string, to prevent duplicate submissions */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
public static function create(object $data): ?static {
/* if missing required fields id or nonce */
if (strlen($data->id ?? '') < 1 || strlen($data->nonce ?? '') < 1 ||
/* if missing both token and password */
(strlen($data->token ?? '') < 1 && strlen($data->password ?? '') < 1)
) {
/* returns null if the input is invalid */
return null;
}
$instance = new static();
$instance->id = $data->id;
if (strlen($data->token ?? '') > 0) {
$instance->token = $data->token;
} else {
$instance->password = $data->password;
}
$instance->nonce = $data->nonce;
$instance->json = ($data->json ?? true);
switch ($data->scope ?? '') {
case 'ip': $instance->scope = Scope::Ip; break;
case 'none': $instance->scope = Scope::None; break;
default: $instance->scope = Scope::Cookie; break;
}
return $instance;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum Scope: string {
case Cookie = 'cookie';
case Ip = 'ip';
case None = 'none';
}
+1
View File
@@ -34,3 +34,4 @@ final class AcceptListener {
}
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\DTO\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\MakeNonceTrait;
use App\Utilities;
use OTPHP\Factory;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
use Twig\Environment;
final class LoginListener {
use MakeNonceTrait;
public function __construct(
private CacheItemPoolInterface $requestCache,
private CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $noncePool,
private ConfigBag $config,
LoggerInterface $logger,
private Utilities $utilities,
private Environment $twig,
) {
$this->requestCache = new MonitorCacheKeys($requestCache);
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->noncePool = $noncePool;
$this->logger = $logger;
}
#[AsEventListener(priority: 88)]
public function onKernelRequest(RequestEvent $event): void {
if ($event->getRequest()->headers->has('x-preauth')) {
$this->logger->debug("x-preauth has been provided");
$data = $event->getRequest()->headers->get('x-preauth');
$payload = $this->decode($data);
if ($payload) {
$this->logger->debug("x-preauth has valid payload");
/* if using token */
if ($payload->token) {
$this->logger->debug("payload has token: $payload->token");
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie;
}
if ($payload->scope === Scope::None && $payload->json) {
/* when scope is None, json *must* be false */
$payload->json = false;
}
$otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(), $this->config->clock()
);
$this->logger->debug("token given: $payload->token, token calculated: " . $otp->now());
if ($otp->verify($payload->token, leeway: 10)) {
$this->logger->debug("tokens detected to be matching");
/* token is correct */
/* if server nonce is found and is valid */
$nonceItem = $this->noncePool->getItem($payload->nonce);
$this->logger->debug("given nonce: $payload->nonce, is it a hit? " . ($nonceItem->isHit() ? 'hit' : 'miss') . ', is it valid? ' . ($nonceItem->get() ? 'valid' : 'NOPE'));
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(60); /* keep for 1 minute */
$this->noncePool->save($nonceItem);
/* token authentication successful, grant access and set response */
$cleanId = $this->utilities->makeCacheKey($payload->id);
/* if they just want this one page, return ok, to grant them access */
$response = new Response("hi $cleanId");
if ($payload->scope !== Scope::None) {
/* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) {
$response->headers->setCookie($this->setCookie($cleanId));
} else if ($payload->scope === Scope::Ip) {
$this->setIp($cleanId, $event->getRequest()->getClientIp());
}
if ($payload->json) {
$contentType = 'application/json';
$content = json_encode([
'message' => 'Login successful',
'nonce' => null,
]);
} else {
$contentType = 'text/html';
$content = "hi $cleanId, please reload";
}
$response->setContent($content)
->setStatusCode(Response::HTTP_TEMPORARY_REDIRECT)
->headers->set('location',
"{$event->getRequest()->getPathInfo()}{$event->getRequest()->getQueryString()}"
);
$response->headers->set('content-type', $contentType);
}
$this->logger->debug("successful login for: $cleanId");
$event->setResponse($response);
return;
}
}
} else if ($this->config->staticSecret()) {
/* when using password, we never set cookie, nor grant ip access, nor return json */
$payload->json = false;
$payload->scope = Scope::None;
/* if password is correct */
if ($payload->password === $this->config->staticSecret()) {
/* password is correct */
/* nonce *may* be client provided, but must still be unique */
/* if server/client nonce is acceptable (valid server or unused client) */
$nonceItem = $this->noncePool->getItem($payload->nonce);
if (($nonceItem->isHit() && $nonceItem->get()) || ! $nonceItem->isHit()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(60); /* keep for 1 minute */
$this->noncePool->save($nonceItem);
/* password authentication successful, grant access and set response */
$this->logger->debug("successful login for: $cleanId");
$event->setResponse(new Response("hi $cleanId"));
return;
}
}
}
}
/* x-preauth was set, but didn't succeed at authenticating
* invalid payload, wrong token, spent nonce, etc */
$json = $payload->json ?? true;
// TODO track failures.. try to identify if is a duplicate request, in which case, don't
// use rate-limiting symfony system... then update RejectListener to work the same way...
/* hash the query, so we do not count duplicates
* hitting refresh a few times should not lock you out */
$ipKey = $this->utilities->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
$failuresItem = $this->requestCache->getItem($ipKey);
$failures = $failuresItem->get() ?? [];
$failures[hash('xxh3', $data)] = true;
$failuresItem->set($failures);
$failuresItem->expiresAfter(count($failures) >= $this->config->limit())
? $this->config->limitTtl() : $this->config->limitTimeout()
);
$this->requestCache->save($failuresItem);
if ($json) {
$contentType = 'application/json';
$content = json_encode([
'message' => (count($failures) >= $this->config->limit()) ? 'I am a teapot' : 'Unsuccessful login attempt',
'nonce' => $this->makeNonce(),
]);
} else {
$contentType = 'text/html';
$content = $this->twig->render('login.html.twig', [
'base_domain' => $this->utilities->baseDomain($event->getRequest()->getHost()),
'nonce_value' => $this->makeNonce(),
]);
}
$this->logger->debug("loggin failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse(new Response($content,
Response::HTTP_UNAUTHORIZED,
["content-type" => $contentType]
));
}
}
private function decode(string $data): ?Payload {
/* convert the base64url into json string ($json could be false) */
$json = base64_decode(str_pad(strtr($data, '-_', '+/'),
strlen($data) % 4, '=', STR_PAD_RIGHT
), true);
/* convert json into a payload (will return null if invalid) */
if ($json) {
return Payload::create(json_decode($json));
}
return null;
}
private function setCookie(string $id): Cookie {
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->utilities->makeCacheKey("cookie_$ulid")
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error("successful login but ULID collision, aborting");
// TODO maybe a pretty error page and/or json...
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
$sessionCookie->set($id);
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionCache->save($sessionCookie);
return Cookie::create(
name: "__Host-Http-Preauth",
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
path: '/',
secure: true,
httpOnly: true
);
}
private function setIp(string $id, string $ip): void {
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->utilities->makeCacheKey("ip_$ip");
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
}
}
+1
View File
@@ -41,3 +41,4 @@ final class RejectListener {
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Trait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpFoundation\Response;
trait MakeNonceTrait {
protected CacheItemPoolInterface $noncePool;
protected LoggerInterface $logger;
protected function makeNonce(int $retries = 3): string {
/* 15 bytes neatly fits in base64 (no trailing "=") */
$nonce = rtrim(strtr(base64_encode(random_bytes(15)), '+/', '-_'), '=');
$nonceItem = $this->noncePool->getItem($nonce);
if ($nonceItem->isHit()) {
if ($retries < 1) {
$this->logger->error("failed to generate unique nonce after multiple attempts, aborting");
// TODO maybe a pretty error page and/or json...
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
return $this->makeNonce($retries - 1);
}
$nonceItem->set(true); /* valid */
$nonceItem->expiresAfter(60); /* keep for 1 minute */
$this->logger->debug("added nonce: '$nonce'");
$this->noncePool->save($nonceItem);
return $nonce;
}
}
@@ -0,0 +1,3 @@
0
__key_list
a:0:{}
@@ -0,0 +1,3 @@
0
__is_dirty
b:0;
@@ -0,0 +1,3 @@
0
__key_list
a:0:{}
@@ -0,0 +1,3 @@
0
__is_dirty
b:0;
@@ -0,0 +1,3 @@
1766103213
suppress
N;
@@ -0,0 +1,3 @@
32503594112
totp
s:138:"otpauth://totp/Preauth-TOTP?secret=5RL5FOJGV4XRKGT74ZVN4725OAM244SU7JYXYX4SHQDTJI4P3YKBYAFUVBBOCLI5XSOLERNB6IQQ54SIGY6QHJ26JM4OP3ZJVBUUBIY";
+53 -9
View File
@@ -2,15 +2,59 @@
{% block content %}
<h1>{{ env.title }}</h1>
{% if has_error|default(false) %}
<p>{{ env.error_message }}</p>
{% endif %}
<form action="/" method="get">
<input type="hidden" name="{{ env.return_field }}" value="{{ return_value }}">
<div class="right"><label for="id">{{ env.id_name }}:</label></div>
<div><input type="text" name="{{ env.id_field }}" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
<div class="right"><label for="token">{{ env.token_name }}:</label></div>
<div><input type="text" name="{{ env.token_field }}" id="token" autocomplete="off" required="required"></div>
<p id="preauth-message">{{ error_message|default }}</p>
<form id="preauth-form">
<input id="preauth-nonce" type="hidden" name="nonce" value="{{ nonce_value }}">
<div class="right"><label for="preauth-id">{{ env.id_name }}:</label></div>
<div><input type="text" name="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="token" id="preauth-token" autocomplete="one-time-code" required="required"></div>
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
<script>
const form = document.getElementById('preauth-form');
//const id = document.getElementById('preauth-id');
//const token = document.getElementById('preauth-token');
//const nonce = document.getElementById('preauth-nonce');
const message = document.getElementById('preauth-message');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = {
id: form.id.value,
token: form.token.value,
nonce: form.nonce.value,
json: true
};
const payload = btoa(JSON.stringify(data))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const response = await fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': payload },
redirect: 'manual'
});
if (response.status >= 300 && response.status < 400) {
const location = res.headers.get('Location');
if (location) {
window.location.href = location;
}
return;
} else {
try {
const content = await response.json();
message.innerHTML = content.message;
form.nonce.value = content.nonce;
form.token.value = '';
} catch (error) {
console.log(error);
}
}
});
</script>
{% endblock %}