Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0813323ac2 | ||
|
|
9114cfd96f | ||
|
|
43e9b7136e | ||
|
|
38124ef66c |
+1
-1
@@ -1,4 +1,4 @@
|
||||
|
||||
/.idea/
|
||||
###> symfony/framework-bundle ###
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
|
||||
+4
-2
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
|
||||
@@ -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 single‑use 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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 case‑insensitive 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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%; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user