4 Commits
Author SHA1 Message Date
andrew 0813323ac2 renamed form fields to work better with password managers; fixed bug where an invalid login requests were not being counted as login attempts; preserve username when using central auth 2026-06-01 16:28:57 -04:00
andrew 9114cfd96f update to php 8.5, backup codes, etc.
modified:   Dockerfile
	modified:   composer.json
	modified:   composer.lock
	modified:   config/packages/twig.yaml
	modified:   config/services.yaml
	modified:   docs/Caddyfile
	modified:   docs/compose.yaml
	renamed:    docs/env.example -> docs/example.env
	modified:   public/index.php
	modified:   readme.md
	modified:   src/Command/GenerateBackupCodesCommand.php
	modified:   src/ConfigBag.php
	modified:   src/Data/Payload.php
	modified:   src/Enum/Scope.php
	modified:   src/Listener/AcceptListener.php
	modified:   src/Listener/AllowListener.php
	modified:   src/Listener/InterceptListener.php
	modified:   src/Listener/LoginListener.php
	modified:   src/MonitorCacheKeys.php
	modified:   src/PersistCache.php
	modified:   src/Service/BackupCodeManager.php
	modified:   src/Service/DomainManager.php
	new file:   src/Service/LoginManager.php
	modified:   src/Trait/CookieNameTrait.php
	modified:   src/Trait/GetTotpTrait.php
	modified:   src/Trait/MakeNonceTrait.php
	modified:   src/Trait/StringTrait.php
	modified:   src/Utilities.php
	modified:   templates/_script.html.twig
	modified:   templates/_style.html.twig
	modified:   templates/base.html.twig
	modified:   templates/login.html.twig
2026-05-29 21:56:42 -04:00
andrew 43e9b7136e auth subdomain tentatively complete.
All domain logic moved into service.
2026-05-22 12:43:02 -04:00
andrew 38124ef66c First draft of backup codes. only created when the command is called. no command yet to expire/review codes.
Also added a few safeguards against excessively long user input.

Started on ability to redirect to auth subdomain (incomplete).
2026-05-21 12:15:35 -04:00
33 changed files with 1022 additions and 567 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
/.idea/
###> symfony/framework-bundle ###
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
+4 -2
View File
@@ -1,5 +1,5 @@
# use build image, to simplify final image
FROM php:8.4-trixie AS build
FROM php:8.5-trixie AS build
# install APCu and composer
RUN pecl install apcu && \
@@ -31,7 +31,7 @@ RUN composer install --no-dev --optimize-autoloader
RUN composer dump-env prod --empty
# start creating final image
FROM dunglas/frankenphp:php8.4-trixie
FROM dunglas/frankenphp:php8.5-trixie
# install APCu
RUN pecl install apcu && \
@@ -51,6 +51,8 @@ COPY --from=build /app /app
COPY ./Caddyfile /etc/frankenphp/Caddyfile
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
RUN echo 'expose_php = off' > $PHP_INI_DIR/conf.d/restrict.ini
# console needs apc to manage cache
RUN echo 'apc.enable_cli = on' > $PHP_INI_DIR/conf.d/console.ini
# app uses var folder for cache storage
VOLUME ["/config", "/data"]
+5 -5
View File
@@ -1,18 +1,18 @@
{
"type": "project",
"license": "proprietary",
"license": "MIT",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"bacon/bacon-qr-code": "^3.0.3",
"runtime/frankenphp-symfony": "^0.2.0",
"bacon/bacon-qr-code": "^3.1.1",
"runtime/frankenphp-symfony": "^1.0.0",
"spomky-labs/otphp": "^11.4.2",
"symfony/cache": "7.4.*",
"symfony/console": "7.4.*",
"symfony/flex": "^2.10",
"symfony/flex": "^2.11",
"symfony/framework-bundle": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/rate-limiter": "7.4.*",
Generated
+257 -243
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -7,16 +7,12 @@ twig:
bg_color: '%env(BG_COLOR)%'
fg_color: '%env(FG_COLOR)%'
error_color: '%env(ERROR_COLOR)%'
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)%'
teapot_message: '%env(TEAPOT_MESSAGE)%'
too_many_title: '%env(TOO_MANY_TITLE)%'
too_many_message: '%env(TOO_MANY_MESSAGE)%'
debug: '%env(SHELL_VERBOSITY)%'
+25 -17
View File
@@ -8,37 +8,40 @@
# https://symfony.com/doc/current/best_practices.html
# #use-parameters-for-application-configuration
parameters:
# --- main variables ---
# --- main options ---
# URI containing secret and config for TOTP, which determines the token to login
# app will generate one, if not provided, but you should copy it to your .env file
# format: "otpauth://totp/<label>?secret=<secret-key>"
env(TOTP_URI): '' # blank to have the app generate one at random
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
env(COOKIE_TTL): '2592000' # default 30 days
# rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
# default is the lower of 2 per half-minute or 10 per hour
env(BURST_COUNT): 2 # 2 per 30 seconds
env(BURST_TIME): 30 # seconds
env(UPPER_COUNT): 10 # 10 per hour
env(UPPER_TIME): 3600 # seconds (1 hour)
# Enable optional redirection to a dedicated authentication subdomain
env(SUBDOMAIN_REDIRECT): '0' # boolean, 1 to enable
# The subdomain (e.g., auth.example.com) to which unauthenticated users are redirected
env(AUTH_SUBDOMAIN): ''
# --- extra variables ---
# query parameter prefix to prevent collisions
env(QUERY_PREFIX): '_preauth_'
# --- extra options ---
# 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
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
# --- styling variables ---
# --- rate limiting ---
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
env(BURST_COUNT): 2 # 2 per 30 seconds
env(BURST_TIME): 30 # seconds
env(UPPER_COUNT): 10 # 10 per hour
env(UPPER_TIME): 3600 # seconds (1 hour)
# --- styling options ---
env(TITLE): 'Pre-Authentication System'
env(BG_COLOR): '#029386'
env(FG_COLOR): '#ffffff'
env(ERROR_COLOR): '#ffb16d'
env(BG_COLOR): '#029386' # teal
env(FG_COLOR): '#ffffff' # white
env(ERROR_COLOR): '#ffb16d' # apricot (light orange)
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
@@ -48,9 +51,14 @@ parameters:
env(TOO_MANY_TITLE): 'Too many requests'
env(TOO_MANY_MESSAGE): 'Try again later'
app.cookie_ttl: '%env(COOKIE_TTL)%'
app.query_prefix: '%env(QUERY_PREFIX)%'
# --- debug options ---
env(SHELL_VERBOSITY): '0' # set to 3 to log debug
# --- application variables ---
app.totp_uri: '%env(TOTP_URI)%'
app.cookie_ttl: '%env(COOKIE_TTL)%'
app.subdomain_redirect: '%env(SUBDOMAIN_REDIRECT)%'
app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%'
app.ip_ttl: '%env(IP_TTL)%'
app.teapot: '%env(TEAPOT)%'
+20 -25
View File
@@ -1,33 +1,28 @@
# if using caddy v2.9.x+ you can use this snippet
# snippet to put the preauth system in front any service easily
(preauth) {
# make sure caddy and preauth are on the same network
reverse_proxy {args[0]} preauth {
# leave body content for protected service
method GET
# if auth is successful, send request to protected service
@preauth_ok status 2xx
handle_response @preauth_ok {
{block}
}
}
}
# example of securing full subdomain
# TODO replace domain and service name
# example of securing full service
# TODO replace domain and service name and port
service.example.com {
import preauth * {
reverse_proxy service_container
forward_auth preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy service-container:80
}
# you can only lock down only select paths
# or any other match criteria, if desired
# https://protected.example.com/secure/
# you can choose to only restrict select paths
# or any other Caddy match criteria, if desired
# IE: https://protected.example.com/secure/
protected.example.com {
import preauth /secure/* {
reverse_proxy protected-service:9000
# note any request that does not start with "/secure/" is NOT protected
forward_auth /secure/* preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy exposed-service:9000
reverse_proxy protected-service:9000
}
# optionally, if you want to use a subdomain for centeral preauth
# set SUBDOMAIN_REDIRECT to true
# and AUTH_SUBDOMAIN to match the subdomain you use here
auth.example.com {
reverse_proxy preauth
}
+2 -5
View File
@@ -1,7 +1,7 @@
services:
preauth:
env_file:
# TODO rename "env.example" to ".env", edit as needed
# TODO rename "example.env" to ".env", edit as needed
# strongly recommend setting TOTP_URI, if not provided the app
# will generate one for you, please copy it into your .env file
- .env
@@ -10,10 +10,7 @@ services:
image: digitaladapt/preauth:latest
restart: unless-stopped
# if you wish to set the user, you must make sure that the user
# can write to /app/var/ within the container, and that all files
# and folders within are writable as well
# IE: `$chown -R <uid>:<gid> /path/to/volume/of/app/var`
#
# can write to /config and /data within the container
#user: <uid>:<gid>
volumes:
- preauth-config:/config
+22 -12
View File
@@ -1,4 +1,4 @@
# --- Main Options ---
# --- main options ---
# URI containing secret and config for TOTP, which determines the token to login
# app will generate one, if not provided, but you should copy it to your .env file
@@ -8,16 +8,14 @@
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
#COOKIE_TTL=2592000 # default 30 days
# NOTE: rate limiting can *NOT* be disabled,
# but you could allow hundreds of logins a second
# we can use a central auth, so that users only need to login once to have access to
# multiple services. Requires using sub-domains under the same domain.
# IE: if enabled have "service-one.example.com" redirect "auth.example.com", and after
# successful auth, user can visit "service-two.example.com" without having to login again.
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
#BURST_COUNT=2 # 2 per 30 seconds
#BURST_TIME=30 # seconds
#UPPER_COUNT=10 # 10 per hour
#UPPER_TIME=3600 # seconds (1 hour)
# --- Extra Options ---
# --- extra options ---
# 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
@@ -26,14 +24,23 @@
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
# --- Styling Options ---
# --- rate limiting ---
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
#BURST_COUNT=2 # 2 per 30 seconds
#BURST_TIME=30 # seconds
#UPPER_COUNT=10 # 10 per hour
#UPPER_TIME=3600 # seconds (1 hour)
# --- styling options ---
#TITLE='Pre-Authentication System'
#BG_COLOR='#029386' # teal
#FG_COLOR='#ffffff' # white
#ERROR_COLOR='#ffb16d' # apricot (light orange)
#ID_NAME='Session ID'
#TOKEN_NAME='Authentication Token'
PASSWORD_NAME='Authentication Password'
#SUBMIT_NAME='Submit'
#ERROR_MESSAGE='Unsuccessful login attempt'
# title and message to use on block page, if teapot is true
@@ -43,3 +50,6 @@ PASSWORD_NAME='Authentication Password'
#TOO_MANY_TITLE='Too many requests'
#TOO_MANY_MESSAGE='Try again later'
# --- debug options ---
#SHELL_VERBOSITY=0 # set to "3" to log debug
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
use App\Kernel;
+19
View File
@@ -25,7 +25,26 @@ First time you spin up the docker container it will generate a TOTP secret (whic
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
## Backup Codes
It is possible to generate single-use backup codes via a console command within the docker container.
```shell
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
```
### History
#### v0.7.0 (May 29th, 2026)
Added ability to generate single-use backup codes.
Removed static password and lookup token, as they were security risks.
Updated to PHP 8.5, updated dependencies.
#### v0.6.0 (Feb 10th, 2026)
Added optional (disabled by default) ability to lookup token by static password.
#### v0.5.0 (Jan 17th, 2026)
Nonce related cleanup; added optional (disabled by default) ability to use a static password as a backup means of authentication.
#### v0.4.1 (Dec 26th, 2025)
Fixed bug which can occur if you delete cache files.
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\PersistCache;
use App\Service\BackupCodeManager;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */
final class GenerateBackupCodesCommand extends Command {
public function __construct(
private readonly BackupCodeManager $manager,
private readonly PersistCache $persistCache,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('app:generate-backup-codes');
$this->setDescription('Generate singleuse backup codes')
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
}
/** @throws InvalidArgumentException */
protected function execute(InputInterface $input, OutputInterface $output): int {
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
$this->persistCache->boot();
$count = (int) $input->getArgument('count');
$codes = $this->manager->generate($count);
foreach ($codes as $code) {
$output->writeln($code);
}
$this->persistCache->persist();
return Command::SUCCESS;
}
}
+15 -22
View File
@@ -10,7 +10,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ConfigBag {
private ClockInterface $clock;
private int $cookieTtl;
private string $queryPrefix;
private string $totpUri;
private ?int $ipTtl;
private bool $teapot;
@@ -20,25 +19,23 @@ final readonly class ConfigBag {
/** @throws InvalidArgumentException */
public function __construct(
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.query_prefix%')] string $queryPrefix,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
) {
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
$this->queryPrefix = $queryPrefix;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->teapot = $teapot;
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->teapot = $teapot;
$this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle;
$this->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle;
}
@@ -50,10 +47,6 @@ final readonly class ConfigBag {
return $this->cookieTtl;
}
public function query(string $field): string {
return "$this->queryPrefix$field";
}
public function totpUri(): string {
return $this->totpUri;
}
+28 -14
View File
@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Data;
use App\Enum\Scope;
use Symfony\Component\HttpFoundation\InputBag;
/* When scope is Ip but ip-access is disabled, scope is to be considered Cookie. */
/* When using password but password is disabled, request will always fail. */
/** when scope is IP but ip-access is disabled, scope is to be considered cookie */
final class Payload {
public string $id; /* session name, identifying who is logging in */
public string $token; /* totp, typically six digits */
public string $token; /* TOTP, typically six digits */
public string $nonce; /* random unique string, to block duplicate submissions */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
@@ -29,27 +29,45 @@ final class Payload {
return null;
}
public static function load(InputBag $input): ?Payload {
/* convert form data into real data */
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[
'id' => $input->get('username'),
'nonce' => $input->get('nonce'),
'token' => $input->get('totp'),
'json' => false,
]);
}
return null;
}
public static function create(object $data): ?Payload {
/* if missing required fields id, nonce, or token */
if (strlen($data->id ?? '') < 1 ||
strlen($data->nonce ?? '') < 1 ||
strlen($data->token ?? '') < 1
if (strlen(trim($data->id ?? '')) < 1 ||
strlen(trim($data->nonce ?? '')) < 1 ||
strlen(trim($data->token ?? '')) < 1
) {
/* returns null as the input is invalid */
return null;
}
/* all input is limited */
$payload = new Payload();
$payload->id = $data->id;
$payload->nonce = $data->nonce;
$payload->id = mb_substr(trim($data->id), 0, 128);
$payload->nonce = mb_substr(trim($data->nonce), 0, 128);
$payload->json = ($data->json ?? true);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = $data->token;
$payload->token = mb_substr(trim($data->token), 0, 128);
return Payload::constrict($payload);
}
public static function constrict(Payload $payload): Payload {
public function toString(): string {
return json_encode($this);
}
private static function constrict(Payload $payload): Payload {
/* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) {
$payload->json = false;
@@ -57,8 +75,4 @@ final class Payload {
return $payload;
}
public function toString(): string {
return json_encode($this);
}
}
+1
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Enum;
/** scope defines the context of how a session is persisted */
enum Scope: string {
case Cookie = 'cookie';
case Ip = 'ip';
+11 -7
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainManager;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
@@ -19,22 +20,25 @@ final readonly class AcceptListener {
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainManager $domainManager,
) {}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 99)]
public function onKernelRequest(RequestEvent $event): void {
/* check if they sent the preauth cookie */
if ($event->getRequest()->cookies->has($this->cookieName())) {
$cookie = $event->getRequest()->cookies->get($this->cookieName());
/* check if they sent the correct preauth cookie */
$cookieName = $this->domainManager->authBase() ?$this->authCookieName() : $this->cookieName();
if ($event->getRequest()->cookies->has($cookieName)) {
$cookie = $event->getRequest()->cookies->get($cookieName);
$cookieKey = $this->makeCacheKey("cookie_$cookie");
if ($this->sessionCache->hasItem($cookieKey)) {
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */
$id = $this->sessionCache->getItem($cookieKey)->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse(new Response("hi $id",
headers: ['Content-Type' => 'text/plain']
));
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
+4 -3
View File
@@ -30,9 +30,10 @@ final readonly class AllowListener {
/* ip address corresponds to valid existing session */
$id = $this->sessionCache->getItem($ipKey)->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse(new Response("hi $id",
headers: ['Content-Type' => 'text/plain']
));
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
+43 -9
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Service\DomainManager;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Twig\Environment;
@@ -16,28 +19,59 @@ use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class InterceptListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
public function __construct(
private ConfigBag $config,
private Environment $twig,
private ConfigBag $config,
private DomainManager $domainManager,
private Environment $twig,
) {}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
#[AsEventListener(priority: 55)]
public function onKernelRequest(RequestEvent $event): void {
if ($event->getRequest()) {
/* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited,
* nor submitting login credentials; so present the login page now */
/* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited,
* nor submitting login credentials; so redirect or present the login page now */
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() &&
$this->domainManager->matchesAuth($event->getRequest()->getHost())
) {
/* host matches base-domain of auth, but not on auth subdomain, redirect */
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
$event->setResponse(new Response('', Response::HTTP_SEE_OTHER,
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
));
} else {
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
$content = $this->twig->render('login.html.twig', [
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
]);
$event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html']
));
$hasCookie = (bool) $event->getRequest()->cookies->get(
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
);
$event->setResponse($this->pruneInvalidCookie(new Response($content,
Response::HTTP_UNAUTHORIZED, ['Content-Type' => 'text/html']
), $hasCookie, $event->getRequest()->getHost()));
}
}
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response {
if ($hasCookie) {
/* input here must match LoginListener::setCookie() */
$response->headers->clearCookie(
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
'/',
/* if using central auth, only set the domain if the host matches */
$this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
true,
true,
Cookie::SAMESITE_STRICT
);
}
return $response;
}
}
+33 -118
View File
@@ -3,25 +3,21 @@ declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Data\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Service\DomainManager;
use App\Service\LoginManager;
use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Uid\Ulid;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
@@ -32,140 +28,57 @@ final readonly class LoginListener {
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
use GetTotpTrait;
private CacheItemPoolInterface $sessionCache;
private RateLimiterFactoryInterface $rateLimiter;
/** @throws InvalidArgumentException */
public function __construct(
private Environment $twig,
CacheItemPoolInterface $sessionCache,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainManager $domainManager,
private LoginManager $loginManager,
private ConfigBag $config,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->rateLimiter = $rateLimiter;
}
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void {
$payload = null;
$response = null;
if ($event->getRequest()->headers->has($this->headerName())) {
/* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName());
$payload = Payload::decode($data);
$response = null;
if ($payload && $payload->token) {
$response = $this->checkToken($payload, $event->getRequest());
}
} else if ($event->getRequest()->isMethod(Request::METHOD_POST) &&
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
) {
/* if request is a POST to the auth-subdomain */
$payload = Payload::load($event->getRequest()->getPayload());
} else {
/* no login attempt detected */
return;
}
/* token or password authentication was successful */
if ($payload) {
/* user sent a valid payload, check it */
$response = $this->loginManager->checkToken($payload, $event->getRequest());
/* token or backup-code authentication was successful */
if ($response) {
$event->setResponse($response);
return;
}
$limitReached = $this->logFailure($event->getRequest());
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true));
}
}
/** @throws InvalidArgumentException */
private function checkToken(Payload $payload, Request $request): ?Response {
/* When scope is Ip but ip-access is disabled, scope will be considered Cookie. */
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie;
}
if ($this->getTotp()->verify($payload->token, null, 10)) {
/* token is correct */
/* login attempted but unsuccessful, log and block if needed */
$limitReached = $this->logFailure($event->getRequest());
/* if server nonce is found and is valid */
$nonceItem = $this->nonceCache->getItem($payload->nonce);
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* token authentication successful, grant access and set response */
$cleanId = $this->makeCacheKey($payload->id);
/* if they just want this one page, return ok, to grant them access */
$response = new Response("hi $cleanId",
headers: ['Content-Type' => 'text/plain']
);
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, $request->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',
"{$request->getPathInfo()}{$request->getQueryString()}"
);
$response->headers->set('Content-Type', $contentType);
}
$this->logger->debug("successful login for: $cleanId");
return $response;
}
}
return null;
}
/** @throws InvalidArgumentException */
private function setCookie(string $id): Cookie {
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid")
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error("aborting: ULID collision");
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: $this->cookieName(),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
secure: true,
sameSite: Cookie::SAMESITE_STRICT
);
}
/** @throws InvalidArgumentException */
private function setIp(string $id, string $ip): void {
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
));
}
private function logFailure(Request $request): bool {
@@ -174,7 +87,7 @@ final readonly class LoginListener {
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
private function makeFailedResponse(bool $limited, bool $json): Response {
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response {
if ($limited) {
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
: Response::HTTP_TOO_MANY_REQUESTS;
@@ -187,6 +100,8 @@ final readonly class LoginListener {
$answer = [
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
];
if ($json) {
+7 -7
View File
@@ -8,13 +8,13 @@ use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
/* We must not store the key-list item or values within this object,
* because it can change from outside this object instance. */
/* we must *NOT* store the key-list item or values within this object
* because it can change from outside this object instance */
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
private const KEY_LIST = '__key_list';
private const CHANGE_LIST = '__chg_list';
public const UPDATED = 1;
public const REMOVED = 2;
private const string KEY_LIST = '__key_list';
private const string CHANGE_LIST = '__chg_list';
public const int UPDATED = 1;
public const int REMOVED = 2;
private CacheItemPoolInterface $cache;
@@ -135,7 +135,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException|OutOfBoundsException */
private function update(CacheItemInterface $item) {
private function update(CacheItemInterface $item): void {
$this->isValid($item->getKey());
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
+1
View File
@@ -7,6 +7,7 @@ use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/* need autoconfigure so we get it from the service container in Kernel->boot() */
#[Autoconfigure(public: true)]
final readonly class PersistCache {
private MonitorCacheKeys $sessionCache;
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\MonitorCacheKeys;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Trait\GetTotpTrait;
/** backup-codes are caseinsensitive alphanumeric strings
* they are single-use and marked as used after successful authentication
*/
final readonly class BackupCodeManager {
use GetTotpTrait;
use HasLoggerTrait;
use StringTrait;
private const int DEFAULT_COUNT = 10;
/* php base_convert() will break if given too long of an input */
const int MAX_LENGTH = 64;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $sessionCache) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/** generate a set of backup-codes and return them
* @param int $count Number of codes to generate
* @return string[] Generated backup codes
* @throws InvalidArgumentException|Exception */
public function generate(int $count = self::DEFAULT_COUNT): array {
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
$codes = [];
for ($i = 0; $i < $count; $i++) {
/* output is alphanumeric string of given length */
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
random_bytes($length)
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
}
$this->saveCodes($codes);
$this->logger->info("generated {$count} backup codes");
return $codes;
}
/** @throws InvalidArgumentException */
public function expire(): void {
$itemsToRemove = [];
foreach ($this->sessionCache->getKeys() as $key) {
if (str_starts_with($key, 'backup_')) {
$itemsToRemove[] = $key;
}
}
if (count($itemsToRemove) > 0) {
$this->sessionCache->deleteItems($itemsToRemove);
}
}
/** check if backup-code is valid and mark it as used
* @param string $code Code supplied by the client
* @return bool true if the code is valid and unused
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool {
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
$this->logger->debug("checking backup code '{$backupKey}': " . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
if ($backupItem->isHit() && $backupItem->get()) {
$this->logger->debug("valid backup code");
/* mark backup code as spent */
$backupItem->set(false); /* used */
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
));
$this->sessionCache->save($backupItem);
return true;
}
return false;
}
/** @throws InvalidArgumentException */
private function saveCodes(array $codes): void {
foreach ($codes as $code) {
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
/* mark backup code as ready */
$backupItem->set(true);
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
));
$this->sessionCache->saveDeferred($backupItem);
}
$this->sessionCache->commit();
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class DomainManager {
/* top-level-domains which are known to have multiple parts */
private const array 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'],
];
private bool $subdomainRedirect;
private string $authSubdomain;
public function __construct(
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
) {
$this->subdomainRedirect = $subdomainRedirect;
$this->authSubdomain = $authSubdomain;
}
/** IE: "auth.example.com" or null if not using a separate subdomain
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string {
if ($this->authBase()) {
return $this->authSubdomain;
}
return null;
}
/** check if given url is an acceptable url for redirection
* @param string $url Where we are thinking of sending the user
* @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool {
/* ensure url is valid and, when using an auth subdomain,
* that the url host matches the base domain */
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return false;
}
if ($this->authBase()) {
$host = parse_url($url, PHP_URL_HOST);
if ($host === null) {
return false;
}
/* do not send the user to another domain */
return $this->matchesAuth($host);
}
return true;
}
/** check if host-base matches auth-base
* @param string $host
* @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool {
$hostBase = $this->baseDomain($host);
$authBase = $this->baseDomain($this->authSubdomain);
return $this->subdomainRedirect && $this->authSubdomain &&
$authBase && $authBase === $hostBase;
}
/** IE: "example.com" if central auth is something like "auth.example.com"
* @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string {
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
return $this->baseDomain($this->authSubdomain);
}
return null;
}
/** this lets us determine the base domain of the given ip, localhost, or domain
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
* things like "localhost" and "8.8.8.8" will return null
* @param string $host ip, localhost, or domain with zero or more subdomains
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
private function baseDomain(string $host): ?string {
/* if host is an ip address (or localhost), leave it as is */
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
return null;
}
$parts = explode('.', $host);
$keep = $this->baseLength($parts);
$parts = array_slice($parts, -$keep);
return implode('.', $parts);
}
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
* @param string[] $parts pieces of a domain split by "." dot
* @return int typically 2 but sometimes 3 */
private function baseLength(array $parts): int {
$length = count($parts);
$baseLength = min(2, $length);
/* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[$length-1]]) &&
in_array($parts[$length-2], self::TLD[$parts[$length-1]], true)
) {
$baseLength = min(3, $length);
}
return $baseLength;
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
final readonly class LoginManager {
use CookieNameTrait;
use GetTotpTrait;
use MakeNonceTrait;
use StringTrait;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $sessionCache,
private BackupCodeManager $backupCodeManager,
private DomainManager $domainManager,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/** @throws InvalidArgumentException */
public function checkToken(Payload $payload, Request $request): ?Response {
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie;
}
if ($this->getTotp()->verify($payload->token, null, 10) ||
$this->backupCodeManager->verifyAndConsume($payload->token)
) {
/* token is correct (TOTP or Backup) */
/* if server nonce is found and is valid */
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* token authentication successful, grant access and set response */
$cleanId = $this->makeCacheKey($payload->id);
/* if they just want this one page, return ok, to grant them access */
$response = new Response("hi $cleanId", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $cleanId,
]);
if ($payload->scope !== Scope::None) {
/* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} else if ($payload->scope === Scope::Ip) {
$this->setIp($cleanId, $request->getClientIp());
}
if ($payload->json) {
$contentType = 'application/json';
$content = json_encode([
'message' => 'Login successful',
'nonce' => null,
]);
} else {
$contentType = 'text/html';
$content = "hi $cleanId, please reload";
}
$location = $request->query->has('return') &&
$this->domainManager->validReturn($request->query->get('return')) ?
"{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}";
/* force redirect to use GET method (important when using central auth) */
$response->setContent($content)
->setStatusCode(Response::HTTP_SEE_OTHER)
->headers->set('Location', $location);
$response->headers->set('Content-Type', $contentType);
}
$this->logger->debug("successful login for: $cleanId");
return $response;
}
}
return null;
}
/** @throws InvalidArgumentException */
private function setCookie(string $id, string $host): Cookie {
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid")
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error("aborting: ULID collision");
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
$sessionCookie->set($id);
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionCache->save($sessionCookie);
/* when using subdomain-auth we have to use a different cookie name, as the
* "__Host-Http-" prefix we normally use does not allow domain to be set */
/* changes here must be reflected in InterceptListener::pruneInvalidCookie() */
return Cookie::create(
name: $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
path: '/',
/* if using central auth, only set the domain if the host matches */
domain: $this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
secure: true,
httpOnly: true,
sameSite: Cookie::SAMESITE_STRICT,
);
}
/** @throws InvalidArgumentException */
private function setIp(string $id, string $ip): void {
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
}
}
+7 -2
View File
@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Trait;
trait CookieNameTrait {
private const COOKIE_NAME = '__Host-Http-Preauth';
private const HEADER_NAME = 'X-Preauth';
private const string COOKIE_NAME = '__Host-Http-Preauth';
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private const string HEADER_NAME = 'X-Preauth';
final protected function cookieName(): string {
return static::COOKIE_NAME;
}
final protected function authCookieName(): string {
return static::AUTH_COOKIE_NAME;
}
final protected function headerName(): string {
return static::HEADER_NAME;
}
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
+4 -3
View File
@@ -12,10 +12,11 @@ use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait {
use HasLoggerTrait;
use StringTrait;
/* 15 bytes neatly fits in base64 */
private const NONCE_LENGTH = 15;
private const NONCE_TTL = 120;
private const int NONCE_LENGTH = 15;
private const int NONCE_TTL = 120;
protected readonly CacheItemPoolInterface $nonceCache;
@@ -30,7 +31,7 @@ trait MakeNonceTrait {
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
)), '+/', '-_'), '=');
$nonceItem = $this->nonceCache->getItem($nonce);
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) {
if ($retries < 1) {
+2 -2
View File
@@ -5,9 +5,9 @@ namespace App\Trait;
trait StringTrait {
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
private const KEY_REGEX = '/[^A-Za-z0-9_.]+/';
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
public function makeCacheKey(string $name): string {
return preg_replace(static::KEY_REGEX, '_', $name);
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
}
}
+1 -7
View File
@@ -48,20 +48,14 @@ final readonly class Utilities {
}
private function showTotp(string $totp): void {
// /* only show this at most, every 5 minutes */
// $suppress = $this->appPool->getItem('suppress');
// if ( ! $suppress->isHit()) {
$writer = new Writer(new PlainTextRenderer());
file_put_contents(
'php://stderr', <<<RAW
{$writer->writeString($totp)}
$totp
loading totp, because the env is not set, please copy above into TOTP_URI
loading TOTP, because the env is not set, please copy above into TOTP_URI
RAW, FILE_APPEND
);
// $suppress->expiresAfter(300);
// $this->appPool->save($suppress);
// }
}
}
+78 -46
View File
@@ -1,52 +1,84 @@
<script>
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
const body = document.getElementById('preauth-body');
const style = document.getElementById('preauth-style');
form.addEventListener('submit', (event) => {
event.preventDefault();
form.addEventListener('submit', (event) => {
event.preventDefault();
/* 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(/=+$/, '');
{# make base64url string containing our payload json object #}
const data = btoa(JSON.stringify({
id: form.username.value?.trim() ?? '',
token: form.totp.value?.trim() ?? '',
nonce: form.nonce.value?.trim() ?? '',
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/* send our request to the server */
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
}).then((response) => {
if (response.headers.has('Location')) {
/* follow redirect (not needed in most browsers) */
window.location.href = response.headers.get('Location');
} else if (response.headers.get('Content-Type') === 'application/json') {
/* got json, update the page */
response.json().then((content) => {
if (Object.hasOwn(content, 'message')) {
message.innerText = content.message;
}
if (Object.hasOwn(content, 'nonce')) {
form.preauth_nonce.value = content.nonce;
form.preauth_token.value = '';
form.preauth_token.focus();
}
}).catch((error) => {
console.log('failed to parse json from response');
console.log(error);
});
} else { /* non-json, non-redirect response */
/* overwrite the page */
response.text().then((text) => {
document.open();
document.write(text);
document.close();
}).catch((error) => {
console.log('failed to get text from response');
console.log(error);
});
}
});
{# send our request to the server #}
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
}).then((response) => {
{% if env.debug > 2 -%}
console.log(response);
{% endif -%}
if (response.headers.has('Location')) {
{# follow redirect (probably not needed) #}
{% if env.debug > 2 -%}
console.log('got redirect response');
{% endif -%}
window.location.href = response.headers.get('Location');
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
{# got json, update the page #}
{% if env.debug > 2 -%}
console.log('got json response');
{% endif -%}
response.json().then((content) => {
if (Object.hasOwn(content, 'message')) {
message.innerText = content.message;
}
if (Object.hasOwn(content, 'nonce')) {
form.nonce.value = content.nonce;
form.totp.value = '';
form.totp.focus();
}
}).catch((error) => {
console.log('failed to parse json from response');
console.log(error);
});
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('text/html') ?? false) {
{# got html, replace the page #}
{% if env.debug > 2 -%}
console.log('got html response');
{% endif -%}
response.text().then((html) => {
document.open();
document.write(html);
document.close();
}).catch((error) => {
console.log('failed to get html from response');
console.log(error);
});
} else {
{# non-json, non-html, non-redirect response #}
{# update the page, change style to plain text #}
{% if env.debug > 2 -%}
console.log('got misc response');
{% endif -%}
response.text().then((text) => {
body.innerText = text;
style.disabled = true;
body.style.whiteSpace = 'pre-wrap';
body.style.wordWrap = 'break-word';
}).catch((error) => {
console.log('failed to get text from response');
console.log(error);
});
}
}).catch((error) => {
console.log('failed to get response');
console.log(error);
});
});
</script>
+1 -1
View File
@@ -1,4 +1,4 @@
<style>
<style id="preauth-style">
* { margin: 0; padding: 0.25em; }
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
+2 -3
View File
@@ -4,10 +4,9 @@
<meta charset="utf-8">
<title>{{ env.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{{ include('_style.html.twig') }}
{{- include('_style.html.twig') -}}
</head>
<body>
<body id="preauth-body">
{% block content %}{% endblock %}
{{ include('_script.html.twig') }}
</body>
</html>
+11 -8
View File
@@ -3,14 +3,17 @@
{% block content %}
<h1>{{ env.title }}</h1>
<p id="preauth-message">{{ message|default }}</p>
<form id="preauth-form">
<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>
<form id="preauth-form" {% if post ?? false -%} method="post" {%- endif %}>
<input id="nonce" type="hidden" name="nonce" value="{{ nonce }}">
<div class="right"><label for="username">{{ env.id_name }}:</label></div>
<div><input type="text" name="username" id="username" {% if username ?? false %}value="{{ username }}"{% endif %}
autocomplete="username" required="required" autofocus="autofocus"></div>
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
<div><input type="text" name="totp" id="totp"
autocomplete="one-time-code" required="required"></div>
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
{% endblock %}