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 ### ###> symfony/framework-bundle ###
/config/secrets/prod/prod.decrypt.private.php /config/secrets/prod/prod.decrypt.private.php
/public/bundles/ /public/bundles/
+4 -2
View File
@@ -1,5 +1,5 @@
# use build image, to simplify final image # 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 # install APCu and composer
RUN pecl install apcu && \ RUN pecl install apcu && \
@@ -31,7 +31,7 @@ RUN composer install --no-dev --optimize-autoloader
RUN composer dump-env prod --empty RUN composer dump-env prod --empty
# start creating final image # start creating final image
FROM dunglas/frankenphp:php8.4-trixie FROM dunglas/frankenphp:php8.5-trixie
# install APCu # install APCu
RUN pecl install apcu && \ RUN pecl install apcu && \
@@ -51,6 +51,8 @@ COPY --from=build /app /app
COPY ./Caddyfile /etc/frankenphp/Caddyfile COPY ./Caddyfile /etc/frankenphp/Caddyfile
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini 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 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 # app uses var folder for cache storage
VOLUME ["/config", "/data"] VOLUME ["/config", "/data"]
+5 -5
View File
@@ -1,18 +1,18 @@
{ {
"type": "project", "type": "project",
"license": "proprietary", "license": "MIT",
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.4",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"bacon/bacon-qr-code": "^3.0.3", "bacon/bacon-qr-code": "^3.1.1",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^1.0.0",
"spomky-labs/otphp": "^11.4.2", "spomky-labs/otphp": "^11.4.2",
"symfony/cache": "7.4.*", "symfony/cache": "7.4.*",
"symfony/console": "7.4.*", "symfony/console": "7.4.*",
"symfony/flex": "^2.10", "symfony/flex": "^2.11",
"symfony/framework-bundle": "7.4.*", "symfony/framework-bundle": "7.4.*",
"symfony/mime": "7.4.*", "symfony/mime": "7.4.*",
"symfony/rate-limiter": "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)%' bg_color: '%env(BG_COLOR)%'
fg_color: '%env(FG_COLOR)%' fg_color: '%env(FG_COLOR)%'
error_color: '%env(ERROR_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)%' id_name: '%env(ID_NAME)%'
token_name: '%env(TOKEN_NAME)%' token_name: '%env(TOKEN_NAME)%'
password_name: '%env(PASSWORD_NAME)%'
submit_name: '%env(SUBMIT_NAME)%' submit_name: '%env(SUBMIT_NAME)%'
error_message: '%env(ERROR_MESSAGE)%' error_message: '%env(ERROR_MESSAGE)%'
teapot_title: '%env(TEAPOT_TITLE)%' teapot_title: '%env(TEAPOT_TITLE)%'
teapot_message: '%env(TEAPOT_MESSAGE)%' teapot_message: '%env(TEAPOT_MESSAGE)%'
too_many_title: '%env(TOO_MANY_TITLE)%' too_many_title: '%env(TOO_MANY_TITLE)%'
too_many_message: '%env(TOO_MANY_MESSAGE)%' 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 # https://symfony.com/doc/current/best_practices.html
# #use-parameters-for-application-configuration # #use-parameters-for-application-configuration
parameters: parameters:
# --- main variables --- # --- main options ---
# URI containing secret and config for TOTP, which determines the token to login # 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 # app will generate one, if not provided, but you should copy it to your .env file
# format: "otpauth://totp/<label>?secret=<secret-key>" # format: "otpauth://totp/<label>?secret=<secret-key>"
env(TOTP_URI): '' # blank to have the app generate one at random 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 # how long will someone stay logged in, measured in seconds, zero for DEFAULT
env(COOKIE_TTL): '2592000' # default 30 days env(COOKIE_TTL): '2592000' # default 30 days
# rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second # Enable optional redirection to a dedicated authentication subdomain
# default is the lower of 2 per half-minute or 10 per hour env(SUBDOMAIN_REDIRECT): '0' # boolean, 1 to enable
env(BURST_COUNT): 2 # 2 per 30 seconds # The subdomain (e.g., auth.example.com) to which unauthenticated users are redirected
env(BURST_TIME): 30 # seconds env(AUTH_SUBDOMAIN): ''
env(UPPER_COUNT): 10 # 10 per hour
env(UPPER_TIME): 3600 # seconds (1 hour)
# --- extra variables --- # --- extra options ---
# query parameter prefix to prevent collisions
env(QUERY_PREFIX): '_preauth_'
# how long do we allow all traffic from an ip address after successful login # 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 # could be useful if you have a system which does not handle cookies
env(IP_TTL): '0' # default disabled, time in seconds 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" # once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean 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(TITLE): 'Pre-Authentication System'
env(BG_COLOR): '#029386' env(BG_COLOR): '#029386' # teal
env(FG_COLOR): '#ffffff' env(FG_COLOR): '#ffffff' # white
env(ERROR_COLOR): '#ffb16d' env(ERROR_COLOR): '#ffb16d' # apricot (light orange)
env(ID_NAME): 'Session ID' env(ID_NAME): 'Session ID'
env(TOKEN_NAME): 'Authentication Token' env(TOKEN_NAME): 'Authentication Token'
env(PASSWORD_NAME): 'Authentication Password'
env(SUBMIT_NAME): 'Submit' env(SUBMIT_NAME): 'Submit'
env(ERROR_MESSAGE): 'Unsuccessful login attempt' env(ERROR_MESSAGE): 'Unsuccessful login attempt'
# title and message to use on block page, if teapot is true # 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_TITLE): 'Too many requests'
env(TOO_MANY_MESSAGE): 'Try again later' env(TOO_MANY_MESSAGE): 'Try again later'
app.cookie_ttl: '%env(COOKIE_TTL)%' # --- debug options ---
app.query_prefix: '%env(QUERY_PREFIX)%' env(SHELL_VERBOSITY): '0' # set to 3 to log debug
# --- application variables ---
app.totp_uri: '%env(TOTP_URI)%' 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.ip_ttl: '%env(IP_TTL)%'
app.teapot: '%env(TEAPOT)%' app.teapot: '%env(TEAPOT)%'
+20 -25
View File
@@ -1,33 +1,28 @@
# if using caddy v2.9.x+ you can use this snippet # example of securing full service
# snippet to put the preauth system in front any service easily # TODO replace domain and service name and port
(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
service.example.com { service.example.com {
import preauth * { forward_auth preauth {
reverse_proxy service_container uri {uri}
copy_headers Remote-User
} }
reverse_proxy service-container:80
} }
# you can only lock down only select paths # you can choose to only restrict select paths
# or any other match criteria, if desired # or any other Caddy match criteria, if desired
# https://protected.example.com/secure/ # IE: https://protected.example.com/secure/
protected.example.com { protected.example.com {
import preauth /secure/* { # note any request that does not start with "/secure/" is NOT protected
reverse_proxy protected-service:9000 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: services:
preauth: preauth:
env_file: 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 # strongly recommend setting TOTP_URI, if not provided the app
# will generate one for you, please copy it into your .env file # will generate one for you, please copy it into your .env file
- .env - .env
@@ -10,10 +10,7 @@ services:
image: digitaladapt/preauth:latest image: digitaladapt/preauth:latest
restart: unless-stopped restart: unless-stopped
# if you wish to set the user, you must make sure that the user # 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 # can write to /config and /data within the container
# and folders within are writable as well
# IE: `$chown -R <uid>:<gid> /path/to/volume/of/app/var`
#
#user: <uid>:<gid> #user: <uid>:<gid>
volumes: volumes:
- preauth-config:/config - 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 # 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 # 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 # how long will someone stay logged in, measured in seconds, zero for DEFAULT
#COOKIE_TTL=2592000 # default 30 days #COOKIE_TTL=2592000 # default 30 days
# NOTE: rate limiting can *NOT* be disabled, # we can use a central auth, so that users only need to login once to have access to
# but you could allow hundreds of logins a second # 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 # --- extra options ---
#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 ---
# how long do we allow *ALL* traffic from an ip address after successful login # 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 # 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" # once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean #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' #TITLE='Pre-Authentication System'
#BG_COLOR='#029386' # teal #BG_COLOR='#029386' # teal
#FG_COLOR='#ffffff' # white #FG_COLOR='#ffffff' # white
#ERROR_COLOR='#ffb16d' # apricot (light orange) #ERROR_COLOR='#ffb16d' # apricot (light orange)
#ID_NAME='Session ID' #ID_NAME='Session ID'
#TOKEN_NAME='Authentication Token' #TOKEN_NAME='Authentication Token'
PASSWORD_NAME='Authentication Password'
#SUBMIT_NAME='Submit' #SUBMIT_NAME='Submit'
#ERROR_MESSAGE='Unsuccessful login attempt' #ERROR_MESSAGE='Unsuccessful login attempt'
# title and message to use on block page, if teapot is true # 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_TITLE='Too many requests'
#TOO_MANY_MESSAGE='Try again later' #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 <?php
declare(strict_types=1);
use App\Kernel; 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. 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 ### 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) #### v0.4.1 (Dec 26th, 2025)
Fixed bug which can occur if you delete cache files. 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 { final readonly class ConfigBag {
private ClockInterface $clock; private ClockInterface $clock;
private int $cookieTtl; private int $cookieTtl;
private string $queryPrefix;
private string $totpUri; private string $totpUri;
private ?int $ipTtl; private ?int $ipTtl;
private bool $teapot; private bool $teapot;
@@ -20,25 +19,23 @@ final readonly class ConfigBag {
/** @throws InvalidArgumentException */ /** @throws InvalidArgumentException */
public function __construct( public function __construct(
Utilities $utilities, Utilities $utilities,
ClockInterface $clock, ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl, #[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.query_prefix%')] string $queryPrefix, #[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.totp_uri%')] string $totpUri, #[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl, #[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.teapot%')] bool $teapot, #[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.error_message%')] string $errorMessage, #[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.teapot_title%')] string $teapotTitle, #[Autowire('%app.too_many_title%')] string $tooManyTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
) { ) {
$this->clock = $clock; $this->clock = $clock;
$this->cookieTtl = $cookieTtl; $this->cookieTtl = $cookieTtl;
$this->queryPrefix = $queryPrefix; $this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->totpUri = $totpUri ?: $utilities->loadTotp(); $this->ipTtl = $ipTtl ?: null;
$this->ipTtl = $ipTtl ?: null; $this->teapot = $teapot;
$this->teapot = $teapot;
$this->errorMessage = $errorMessage; $this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle; $this->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle; $this->tooManyTitle = $tooManyTitle;
} }
@@ -50,10 +47,6 @@ final readonly class ConfigBag {
return $this->cookieTtl; return $this->cookieTtl;
} }
public function query(string $field): string {
return "$this->queryPrefix$field";
}
public function totpUri(): string { public function totpUri(): string {
return $this->totpUri; return $this->totpUri;
} }
+28 -14
View File
@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Data; namespace App\Data;
use App\Enum\Scope; 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 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. */
final class Payload { final class Payload {
public string $id; /* session name, identifying who is logging in */ 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 string $nonce; /* random unique string, to block duplicate submissions */
public bool $json; /* should we return json (for the login page) */ public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */ public Scope $scope; /* type of access being requested */
@@ -29,27 +29,45 @@ final class Payload {
return null; 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 { public static function create(object $data): ?Payload {
/* if missing required fields id, nonce, or token */ /* if missing required fields id, nonce, or token */
if (strlen($data->id ?? '') < 1 || if (strlen(trim($data->id ?? '')) < 1 ||
strlen($data->nonce ?? '') < 1 || strlen(trim($data->nonce ?? '')) < 1 ||
strlen($data->token ?? '') < 1 strlen(trim($data->token ?? '')) < 1
) { ) {
/* returns null as the input is invalid */ /* returns null as the input is invalid */
return null; return null;
} }
/* all input is limited */
$payload = new Payload(); $payload = new Payload();
$payload->id = $data->id; $payload->id = mb_substr(trim($data->id), 0, 128);
$payload->nonce = $data->nonce; $payload->nonce = mb_substr(trim($data->nonce), 0, 128);
$payload->json = ($data->json ?? true); $payload->json = ($data->json ?? true);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie; $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); 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. */ /* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) { if ($payload->scope === Scope::None) {
$payload->json = false; $payload->json = false;
@@ -57,8 +75,4 @@ final class Payload {
return $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; namespace App\Enum;
/** scope defines the context of how a session is persisted */
enum Scope: string { enum Scope: string {
case Cookie = 'cookie'; case Cookie = 'cookie';
case Ip = 'ip'; case Ip = 'ip';
+11 -7
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Listener; namespace App\Listener;
use App\Service\DomainManager;
use App\Trait\CookieNameTrait; use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait; use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait; use App\Trait\StringTrait;
@@ -19,22 +20,25 @@ final readonly class AcceptListener {
public function __construct( public function __construct(
private CacheItemPoolInterface $sessionCache, private CacheItemPoolInterface $sessionCache,
private DomainManager $domainManager,
) {} ) {}
/** @throws InvalidArgumentException */ /** @throws InvalidArgumentException */
#[AsEventListener(priority: 99)] #[AsEventListener(priority: 99)]
public function onKernelRequest(RequestEvent $event): void { public function onKernelRequest(RequestEvent $event): void {
/* check if they sent the preauth cookie */ /* check if they sent the correct preauth cookie */
if ($event->getRequest()->cookies->has($this->cookieName())) { $cookieName = $this->domainManager->authBase() ?$this->authCookieName() : $this->cookieName();
$cookie = $event->getRequest()->cookies->get($this->cookieName()); if ($event->getRequest()->cookies->has($cookieName)) {
$cookie = $event->getRequest()->cookies->get($cookieName);
$cookieKey = $this->makeCacheKey("cookie_$cookie"); $cookieKey = $this->makeCacheKey("cookie_$cookie");
if ($this->sessionCache->hasItem($cookieKey)) { if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */ /* cookie sent corresponds to valid existing session */
$id = $this->sessionCache->getItem($cookieKey)->get(); $id = $this->sessionCache->getItem($cookieKey)->get();
$this->logger->debug("has valid cookie-session: $id"); $this->logger->debug("has valid cookie-session: $id");
$event->setResponse(new Response("hi $id", $event->setResponse(new Response("hi $id", headers: [
headers: ['Content-Type' => 'text/plain'] '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 */ /* ip address corresponds to valid existing session */
$id = $this->sessionCache->getItem($ipKey)->get(); $id = $this->sessionCache->getItem($ipKey)->get();
$this->logger->debug("has valid ip-session: $id"); $this->logger->debug("has valid ip-session: $id");
$event->setResponse(new Response("hi $id", $event->setResponse(new Response("hi $id", headers: [
headers: ['Content-Type' => 'text/plain'] 'Content-Type' => 'text/plain',
)); 'Remote-User' => $id,
]));
} }
} }
} }
+43 -9
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Listener; namespace App\Listener;
use App\ConfigBag; use App\ConfigBag;
use App\Service\DomainManager;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait; use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait; use App\Trait\MakeNonceTrait;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
use Twig\Environment; use Twig\Environment;
@@ -16,28 +19,59 @@ use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError; use Twig\Error\SyntaxError;
final readonly class InterceptListener { final readonly class InterceptListener {
use CookieNameTrait;
use HasLoggerTrait; use HasLoggerTrait;
use MakeNonceTrait; use MakeNonceTrait;
public function __construct( public function __construct(
private ConfigBag $config, private ConfigBag $config,
private Environment $twig, private DomainManager $domainManager,
private Environment $twig,
) {} ) {}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */ /** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
#[AsEventListener(priority: 55)] #[AsEventListener(priority: 55)]
public function onKernelRequest(RequestEvent $event): void { public function onKernelRequest(RequestEvent $event): void {
if ($event->getRequest()) { /* by this point, we know that the request we have is:
/* by this point, we know that the request we have is: * not already authorized, nor already rate-limited,
* not already authorized, nor already rate-limited, * nor submitting login credentials; so redirect or present the login page now */
* nor submitting login credentials; so 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()}"); $this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
$content = $this->twig->render('login.html.twig', [ $content = $this->twig->render('login.html.twig', [
'nonce' => $this->makeNonce(), 'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
]); ]);
$event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED, $hasCookie = (bool) $event->getRequest()->cookies->get(
['Content-Type' => 'text/html'] $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; namespace App\Listener;
use App\ConfigBag;
use App\Data\Payload; use App\Data\Payload;
use App\Enum\Scope; use App\Service\DomainManager;
use App\MonitorCacheKeys; use App\Service\LoginManager;
use App\Trait\CookieNameTrait; use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\HasLoggerTrait; use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait; use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait; use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Uid\Ulid;
use Twig\Environment; use Twig\Environment;
use Twig\Error\LoaderError; use Twig\Error\LoaderError;
use Twig\Error\RuntimeError; use Twig\Error\RuntimeError;
@@ -32,140 +28,57 @@ final readonly class LoginListener {
use HasLoggerTrait; use HasLoggerTrait;
use MakeNonceTrait; use MakeNonceTrait;
use StringTrait; use StringTrait;
use GetTotpTrait;
private CacheItemPoolInterface $sessionCache;
private RateLimiterFactoryInterface $rateLimiter; private RateLimiterFactoryInterface $rateLimiter;
/** @throws InvalidArgumentException */
public function __construct( public function __construct(
private Environment $twig, private Environment $twig,
CacheItemPoolInterface $sessionCache,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter, #[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainManager $domainManager,
private LoginManager $loginManager,
private ConfigBag $config,
) { ) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->rateLimiter = $rateLimiter; $this->rateLimiter = $rateLimiter;
} }
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */ /** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)] #[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void { public function onKernelRequest(RequestEvent $event): void {
$payload = null;
$response = null;
if ($event->getRequest()->headers->has($this->headerName())) { if ($event->getRequest()->headers->has($this->headerName())) {
/* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName()); $data = $event->getRequest()->headers->get($this->headerName());
$payload = Payload::decode($data); $payload = Payload::decode($data);
$response = null; } else if ($event->getRequest()->isMethod(Request::METHOD_POST) &&
if ($payload && $payload->token) { $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
$response = $this->checkToken($payload, $event->getRequest()); ) {
} /* 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) { if ($response) {
$event->setResponse($response); $event->setResponse($response);
return; 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)) { /* login attempted but unsuccessful, log and block if needed */
/* token is correct */ $limitReached = $this->logFailure($event->getRequest());
/* if server nonce is found and is valid */ $this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$nonceItem = $this->nonceCache->getItem($payload->nonce); $event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
if ($nonceItem->isHit() && $nonceItem->get()) { $event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
/* 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);
} }
private function logFailure(Request $request): bool { private function logFailure(Request $request): bool {
@@ -174,7 +87,7 @@ final readonly class LoginListener {
} }
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */ /** @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) { if ($limited) {
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT $status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
: Response::HTTP_TOO_MANY_REQUESTS; : Response::HTTP_TOO_MANY_REQUESTS;
@@ -187,6 +100,8 @@ final readonly class LoginListener {
$answer = [ $answer = [
'message' => $message, 'message' => $message,
'nonce' => $this->makeNonce(), 'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
]; ];
if ($json) { if ($json) {
+7 -7
View File
@@ -8,13 +8,13 @@ use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
/* We must not store the key-list item or values within this object, /* we must *NOT* store the key-list item or values within this object
* because it can change from outside this object instance. */ * because it can change from outside this object instance */
final readonly class MonitorCacheKeys implements CacheItemPoolInterface { final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
private const KEY_LIST = '__key_list'; private const string KEY_LIST = '__key_list';
private const CHANGE_LIST = '__chg_list'; private const string CHANGE_LIST = '__chg_list';
public const UPDATED = 1; public const int UPDATED = 1;
public const REMOVED = 2; public const int REMOVED = 2;
private CacheItemPoolInterface $cache; private CacheItemPoolInterface $cache;
@@ -135,7 +135,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
} }
/** @throws InvalidArgumentException|OutOfBoundsException */ /** @throws InvalidArgumentException|OutOfBoundsException */
private function update(CacheItemInterface $item) { private function update(CacheItemInterface $item): void {
$this->isValid($item->getKey()); $this->isValid($item->getKey());
$keyList = $this->cache->getItem(self::KEY_LIST); $keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get(); $keyValues = $keyList->get();
+1
View File
@@ -7,6 +7,7 @@ use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/* need autoconfigure so we get it from the service container in Kernel->boot() */
#[Autoconfigure(public: true)] #[Autoconfigure(public: true)]
final readonly class PersistCache { final readonly class PersistCache {
private MonitorCacheKeys $sessionCache; 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; namespace App\Trait;
trait CookieNameTrait { trait CookieNameTrait {
private const COOKIE_NAME = '__Host-Http-Preauth'; private const string COOKIE_NAME = '__Host-Http-Preauth';
private const HEADER_NAME = 'X-Preauth'; private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private const string HEADER_NAME = 'X-Preauth';
final protected function cookieName(): string { final protected function cookieName(): string {
return static::COOKIE_NAME; return static::COOKIE_NAME;
} }
final protected function authCookieName(): string {
return static::AUTH_COOKIE_NAME;
}
final protected function headerName(): string { final protected function headerName(): string {
return static::HEADER_NAME; return static::HEADER_NAME;
} }
+1
View File
@@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Trait; namespace App\Trait;
+4 -3
View File
@@ -12,10 +12,11 @@ use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait { trait MakeNonceTrait {
use HasLoggerTrait; use HasLoggerTrait;
use StringTrait;
/* 15 bytes neatly fits in base64 */ /* 15 bytes neatly fits in base64 */
private const NONCE_LENGTH = 15; private const int NONCE_LENGTH = 15;
private const NONCE_TTL = 120; private const int NONCE_TTL = 120;
protected readonly CacheItemPoolInterface $nonceCache; protected readonly CacheItemPoolInterface $nonceCache;
@@ -30,7 +31,7 @@ trait MakeNonceTrait {
$nonce = rtrim(strtr(base64_encode(random_bytes( $nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH static::NONCE_LENGTH
)), '+/', '-_'), '='); )), '+/', '-_'), '=');
$nonceItem = $this->nonceCache->getItem($nonce); $nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) { if ($nonceItem->isHit()) {
if ($retries < 1) { if ($retries < 1) {
+2 -2
View File
@@ -5,9 +5,9 @@ namespace App\Trait;
trait StringTrait { trait StringTrait {
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */ /* 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 { 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 { 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()); $writer = new Writer(new PlainTextRenderer());
file_put_contents( file_put_contents(
'php://stderr', <<<RAW 'php://stderr', <<<RAW
{$writer->writeString($totp)} {$writer->writeString($totp)}
$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 RAW, FILE_APPEND
); );
// $suppress->expiresAfter(300);
// $this->appPool->save($suppress);
// }
} }
} }
+78 -46
View File
@@ -1,52 +1,84 @@
<script> <script>
const form = document.getElementById('preauth-form'); const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message'); const message = document.getElementById('preauth-message');
const body = document.getElementById('preauth-body');
const style = document.getElementById('preauth-style');
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
/* make base64url string containing our payload json object */ {# make base64url string containing our payload json object #}
const data = btoa(JSON.stringify({ const data = btoa(JSON.stringify({
id: form.preauth_id.value, id: form.username.value?.trim() ?? '',
token: form.preauth_token.value, token: form.totp.value?.trim() ?? '',
nonce: form.preauth_nonce.value, nonce: form.nonce.value?.trim() ?? '',
json: true json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/* send our request to the server */ {# send our request to the server #}
fetch(window.location.href, { fetch(window.location.href, {
method: 'GET', method: 'GET',
headers: { 'X-Preauth': data }, headers: { 'X-Preauth': data },
}).then((response) => { }).then((response) => {
if (response.headers.has('Location')) { {% if env.debug > 2 -%}
/* follow redirect (not needed in most browsers) */ console.log(response);
window.location.href = response.headers.get('Location'); {% endif -%}
} else if (response.headers.get('Content-Type') === 'application/json') { if (response.headers.has('Location')) {
/* got json, update the page */ {# follow redirect (probably not needed) #}
response.json().then((content) => { {% if env.debug > 2 -%}
if (Object.hasOwn(content, 'message')) { console.log('got redirect response');
message.innerText = content.message; {% endif -%}
} window.location.href = response.headers.get('Location');
if (Object.hasOwn(content, 'nonce')) { } else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
form.preauth_nonce.value = content.nonce; {# got json, update the page #}
form.preauth_token.value = ''; {% if env.debug > 2 -%}
form.preauth_token.focus(); console.log('got json response');
} {% endif -%}
}).catch((error) => { response.json().then((content) => {
console.log('failed to parse json from response'); if (Object.hasOwn(content, 'message')) {
console.log(error); message.innerText = content.message;
}); }
} else { /* non-json, non-redirect response */ if (Object.hasOwn(content, 'nonce')) {
/* overwrite the page */ form.nonce.value = content.nonce;
response.text().then((text) => { form.totp.value = '';
document.open(); form.totp.focus();
document.write(text); }
document.close(); }).catch((error) => {
}).catch((error) => { console.log('failed to parse json from response');
console.log('failed to get text from response'); console.log(error);
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> </script>
+1 -1
View File
@@ -1,4 +1,4 @@
<style> <style id="preauth-style">
* { margin: 0; padding: 0.25em; } * { margin: 0; padding: 0.25em; }
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table; 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%; } 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"> <meta charset="utf-8">
<title>{{ env.title }}</title> <title>{{ env.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{{ include('_style.html.twig') }} {{- include('_style.html.twig') -}}
</head> </head>
<body> <body id="preauth-body">
{% block content %}{% endblock %} {% block content %}{% endblock %}
{{ include('_script.html.twig') }}
</body> </body>
</html> </html>
+11 -8
View File
@@ -3,14 +3,17 @@
{% block content %} {% block content %}
<h1>{{ env.title }}</h1> <h1>{{ env.title }}</h1>
<p id="preauth-message">{{ message|default }}</p> <p id="preauth-message">{{ message|default }}</p>
<form id="preauth-form"> <form id="preauth-form" {% if post ?? false -%} method="post" {%- endif %}>
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce }}"> <input id="nonce" type="hidden" name="nonce" value="{{ nonce }}">
<div class="right"><label for="preauth-id">{{ env.id_name }}:</label></div> <div class="right"><label for="username">{{ env.id_name }}:</label></div>
<div><input type="text" name="preauth_id" id="preauth-id" <div><input type="text" name="username" id="username" {% if username ?? false %}value="{{ username }}"{% endif %}
autocomplete="username" required="required" autofocus="autofocus"></div> autocomplete="username" required="required" autofocus="autofocus"></div>
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div> <div class="right"><label for="totp">{{ env.token_name }}:</label></div>
<div><input type="text" name="preauth_token" id="preauth-token" <div><input type="text" name="totp" id="totp"
autocomplete="one-time-code" required="required"></div> autocomplete="one-time-code" required="required"></div>
<div class="center"><button type="submit">{{ env.submit_name }}</button></div> <div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form> </form>
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
{% endblock %} {% endblock %}