Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0813323ac2 | ||
|
|
9114cfd96f | ||
|
|
43e9b7136e | ||
|
|
38124ef66c | ||
|
|
6ed1ab26f1 | ||
|
|
a0dc1a6049 | ||
|
|
3d28485921 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
|
||||
/.idea/
|
||||
###> symfony/framework-bundle ###
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
|
||||
+4
-4
@@ -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 && \
|
||||
@@ -12,7 +12,6 @@ RUN apt-get update && \
|
||||
ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
ENV DEFAULT_URI='http://'
|
||||
|
||||
# load application into build image
|
||||
RUN mkdir -p /data/preauth
|
||||
@@ -32,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 && \
|
||||
@@ -42,7 +41,6 @@ RUN pecl install apcu && \
|
||||
ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
ENV DEFAULT_URI='http://'
|
||||
|
||||
# load application into final image
|
||||
WORKDIR /app
|
||||
@@ -53,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"]
|
||||
|
||||
+7
-11
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"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",
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"spomky-labs/otphp": "^11.3",
|
||||
"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",
|
||||
"symfony/flex": "^2.11",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
@@ -34,11 +35,6 @@
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
|
||||
Generated
+416
-268
File diff suppressed because it is too large
Load Diff
@@ -2,38 +2,14 @@ framework:
|
||||
cache:
|
||||
app: cache.adapter.filesystem
|
||||
pools:
|
||||
noncePool:
|
||||
adapters:
|
||||
- cache.adapter.apcu
|
||||
sessionPool:
|
||||
adapters:
|
||||
- cache.adapter.apcu
|
||||
requestPool:
|
||||
adapters:
|
||||
- cache.adapter.apcu
|
||||
persistSessionPool:
|
||||
adapters:
|
||||
- cache.adapter.filesystem
|
||||
persistRequestPool:
|
||||
adapters:
|
||||
- cache.adapter.filesystem
|
||||
nonceCache:
|
||||
adapters: cache.adapter.apcu
|
||||
rateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
sessionCache:
|
||||
adapters: cache.adapter.apcu
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.filesystem
|
||||
|
||||
# Unique name of your app: used to compute
|
||||
# stable namespaces for cache keys.
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
prefix_seed: digitaladapt/preauth
|
||||
|
||||
# The "app" cache stores to the filesystem by default.
|
||||
# The data in this cache should persist between deploys.
|
||||
# Other options include:
|
||||
|
||||
# Redis
|
||||
#app: cache.adapter.redis
|
||||
#default_redis_provider: redis://localhost
|
||||
|
||||
# APCu (not recommended with heavy random-write workloads
|
||||
# as memory fragmentation can cause perf issues)
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
|
||||
@@ -7,12 +7,3 @@ framework:
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
burst:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:BURST_COUNT)%'
|
||||
interval: '%env(int:BURST_TIME)% seconds'
|
||||
cache_pool: 'rateLimitCache'
|
||||
upper:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:UPPER_COUNT)%'
|
||||
interval: '%env(int:UPPER_TIME)% seconds'
|
||||
cache_pool: 'rateLimitCache'
|
||||
login_limiter:
|
||||
policy: compound
|
||||
limiters: [burst, upper]
|
||||
@@ -1,10 +1,6 @@
|
||||
framework:
|
||||
router:
|
||||
# Configure how to generate URLs in non-HTTP contexts,
|
||||
# such as CLI commands. See
|
||||
# https://symfony.com/doc/current/routing.html
|
||||
# #generating-urls-in-commands
|
||||
default_uri: '%env(DEFAULT_URI)%'
|
||||
default_uri: 'http://localhost'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
|
||||
@@ -3,21 +3,16 @@ twig:
|
||||
strict_variables: true
|
||||
globals:
|
||||
env:
|
||||
allow_password: '%env(STATIC_SECRET_ENABLED)%'
|
||||
title: '%env(TITLE)%'
|
||||
bg_color: '%env(BG_COLOR)%'
|
||||
fg_color: '%env(FG_COLOR)%'
|
||||
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)%'
|
||||
|
||||
+404
-407
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
||||
|
||||
# This file is the entry point to configure the routes of your app.
|
||||
# Methods with the #[Route] attribute are automatically imported.
|
||||
# See also https://symfony.com/doc/current/routing.html
|
||||
|
||||
# To list all registered routes, run the following command:
|
||||
# bin/console debug:router
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
@@ -1,4 +0,0 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||
prefix: /_error
|
||||
+25
-28
@@ -8,42 +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
|
||||
# number of consecutive failed login attempts before we block the ip address
|
||||
env(LIMIT): '4' # default 4 failed login attempts before blocking
|
||||
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
|
||||
env(LIMIT_TIMEOUT): '21600' # default 6 hours
|
||||
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
|
||||
env(LIMIT_TTL): '86400' # default 24 hours
|
||||
# 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
|
||||
# if desired, in addition to supporting a TOTP, you can set a static password
|
||||
env(STATIC_SECRET_ENABLED): '0' # boolean
|
||||
env(STATIC_SECRET): '' # default disabled
|
||||
env(LOOKUP_TOTP): '' # default disabled
|
||||
# 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
|
||||
@@ -53,17 +51,16 @@ parameters:
|
||||
env(TOO_MANY_TITLE): 'Too many requests'
|
||||
env(TOO_MANY_MESSAGE): 'Try again later'
|
||||
|
||||
app.cookie_ttl: '%env(COOKIE_TTL)%'
|
||||
app.limit: '%env(LIMIT)%'
|
||||
app.limit_timeout: '%env(LIMIT_TIMEOUT)%'
|
||||
app.limit_ttl: '%env(LIMIT_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.static_secret_enabled: '%env(STATIC_SECRET_ENABLED)%'
|
||||
app.static_secret: '%env(STATIC_SECRET)%'
|
||||
app.lookup_totp: '%env(LOOKUP_TOTP)%'
|
||||
app.teapot: '%env(TEAPOT)%'
|
||||
|
||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||
|
||||
+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,43 +8,39 @@
|
||||
# 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
|
||||
|
||||
# number of consecutive failed login attempts before we block the ip address
|
||||
#LIMIT=4 # default 4 failed login attempts before blocking
|
||||
|
||||
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
|
||||
#LIMIT_TIMEOUT=21600 # default 6 hours
|
||||
|
||||
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
|
||||
#LIMIT_TTL=86400 # default 24 hours
|
||||
|
||||
# --- 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
|
||||
#IP_TTL=0 # default disabled, time in seconds
|
||||
|
||||
# if desired, in addition to supporting a TOTP, you can set a static password
|
||||
# if you enable a static secret, you may choose to also enable a totp-lookup,
|
||||
# which contains a special path, when successfully authenticated,
|
||||
# instead of returning "hi <your-name>" it returns "code <totp-code>".
|
||||
#STATIC_SECRET_ENABLED='0' # boolean, disabled by default
|
||||
#STATIC_SECRET='' # default disabled
|
||||
#LOOKUP_TOTP='' # default disabled
|
||||
|
||||
# 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
|
||||
@@ -54,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;
|
||||
|
||||
|
||||
@@ -19,13 +19,32 @@ There is an example Caddyfile in /docs/ and env.example file to get you started.
|
||||
|
||||
When someone tries to reach your protected web service, Caddy will check with preauth if they are allowed, if their preauth cookie is missing, invalid, or expired, we will show them to a login screen.
|
||||
|
||||
I say login, but it's really just a TOTP code (6 digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
|
||||
I say login, but it's really just a TOTP code (6-digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
|
||||
|
||||
First time you spin up the docker container it will generate a TOTP secret (which you'll load into your authenticator app); or generate you own.
|
||||
|
||||
Be sure to save that TOTP secret to your docker environment, so that it persistents beyond removing the container.
|
||||
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
-58
@@ -10,14 +10,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
final readonly class ConfigBag {
|
||||
private ClockInterface $clock;
|
||||
private int $cookieTtl;
|
||||
private int $limit;
|
||||
private int $limitTimeout;
|
||||
private int $limitTtl;
|
||||
private string $queryPrefix;
|
||||
private string $totpUri;
|
||||
private ?int $ipTtl;
|
||||
private ?string $lookupTotp;
|
||||
private ?string $staticSecret;
|
||||
private bool $teapot;
|
||||
private string $errorMessage;
|
||||
private string $teapotTitle;
|
||||
@@ -25,36 +19,23 @@ final readonly class ConfigBag {
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
Utilities $utilities,
|
||||
ClockInterface $clock,
|
||||
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
|
||||
#[Autowire('%app.limit%')] int $limit,
|
||||
#[Autowire('%app.limit_timeout%')] int $limitTimeout,
|
||||
#[Autowire('%app.limit_ttl%')] int $limitTtl,
|
||||
#[Autowire('%app.query_prefix%')] string $queryPrefix,
|
||||
#[Autowire('%app.totp_uri%')] string $totpUri,
|
||||
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
||||
#[Autowire('%app.lookup_totp%')] ?string $lookupTotp,
|
||||
#[Autowire('%app.static_secret_enabled%')] bool $staticSecretEnabled,
|
||||
#[Autowire('%app.static_secret%')] ?string $staticSecret,
|
||||
#[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->limit = ($limit >= 1) ? $limit : 4;
|
||||
$this->limitTimeout = ($limitTimeout >= 1) ? $limitTimeout : 21600;
|
||||
$this->limitTtl = ($limitTtl >= 1) ? $limitTtl : 86400;
|
||||
$this->queryPrefix = $queryPrefix;
|
||||
$this->totpUri = $totpUri ?: $utilities->loadTotp();
|
||||
$this->ipTtl = $ipTtl ?: null;
|
||||
$this->staticSecret = $staticSecretEnabled ? ($staticSecret ?: null) : null;
|
||||
$this->lookupTotp = $this->staticSecret ? ($lookupTotp ?: null) : 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;
|
||||
}
|
||||
|
||||
@@ -66,22 +47,6 @@ final readonly class ConfigBag {
|
||||
return $this->cookieTtl;
|
||||
}
|
||||
|
||||
public function limit(): int {
|
||||
return $this->limit;
|
||||
}
|
||||
|
||||
public function limitTimeout(): int {
|
||||
return $this->limitTimeout;
|
||||
}
|
||||
|
||||
public function limitTtl(): int {
|
||||
return $this->limitTtl;
|
||||
}
|
||||
|
||||
public function query(string $field): string {
|
||||
return "$this->queryPrefix$field";
|
||||
}
|
||||
|
||||
public function totpUri(): string {
|
||||
return $this->totpUri;
|
||||
}
|
||||
@@ -90,14 +55,6 @@ final readonly class ConfigBag {
|
||||
return $this->ipTtl;
|
||||
}
|
||||
|
||||
public function lookupTotp(): ?string {
|
||||
return $this->lookupTotp;
|
||||
}
|
||||
|
||||
public function staticSecret(): ?string {
|
||||
return $this->staticSecret;
|
||||
}
|
||||
|
||||
public function teapot(): bool {
|
||||
return $this->teapot;
|
||||
}
|
||||
|
||||
+32
-34
@@ -4,16 +4,15 @@ 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 $password; /* static secret, alternative to token, if enabled */
|
||||
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 */
|
||||
public string $id; /* session name, identifying who is logging in */
|
||||
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 */
|
||||
|
||||
public static function decode(string $base64url): ?Payload {
|
||||
/* convert the base64url into json string */
|
||||
@@ -30,42 +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 or nonce */
|
||||
if (strlen($data->id ?? '') < 1 ||
|
||||
strlen($data->nonce ?? '') < 1 ||
|
||||
/* if missing both token and password (we require one of them) */
|
||||
(strlen($data->token ?? '') < 1 &&
|
||||
strlen($data->password ?? '') < 1)
|
||||
/* if missing required fields id, nonce, or token */
|
||||
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;
|
||||
|
||||
/* we accept either a token or a password, not both */
|
||||
if (strlen($data->token ?? '') > 0) {
|
||||
$payload->token = $data->token;
|
||||
$payload->password = null;
|
||||
} else {
|
||||
$payload->token = null;
|
||||
$payload->password = $data->password;
|
||||
}
|
||||
$payload->token = mb_substr(trim($data->token), 0, 128);
|
||||
|
||||
return Payload::constrict($payload);
|
||||
}
|
||||
|
||||
public static function constrict(Payload $payload): Payload {
|
||||
/* When using password, scope will be considered None. */
|
||||
if ($payload->password) {
|
||||
$payload->scope = Scope::None;
|
||||
}
|
||||
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;
|
||||
@@ -73,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,38 +3,42 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\Service\DomainManager;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AcceptListener {
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionPool,
|
||||
private LoggerInterface $logger,
|
||||
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->sessionPool->hasItem($cookieKey)) {
|
||||
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
|
||||
/* cookie sent corresponds to valid existing session */
|
||||
$id = $this->sessionPool->getItem($cookieKey)->get();
|
||||
$id = $this->sessionCache->getItem($cookieKey)->get();
|
||||
$this->logger->debug("has valid cookie-session: $id");
|
||||
$event->setResponse(new Response("hi $id",
|
||||
headers: ['Content-Type' => 'text/plain']
|
||||
));
|
||||
$event->setResponse(new Response("hi $id", headers: [
|
||||
'Content-Type' => 'text/plain',
|
||||
'Remote-User' => $id,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@ declare(strict_types=1);
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AllowListener {
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionPool,
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private ConfigBag $config,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
@@ -26,13 +26,14 @@ final readonly class AllowListener {
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
if ($this->config->ipTtl() > 0) {
|
||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||
if ($this->sessionPool->hasItem($ipKey)) {
|
||||
if ($this->sessionCache->hasItem($ipKey)) {
|
||||
/* ip address corresponds to valid existing session */
|
||||
$id = $this->sessionPool->getItem($ipKey)->get();
|
||||
$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,11 +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\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Twig\Environment;
|
||||
@@ -17,33 +19,59 @@ use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class InterceptListener {
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $requestPool,
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
CacheItemPoolInterface $noncePool,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->logger = $logger;
|
||||
$this->noncePool = $noncePool;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+43
-185
@@ -5,22 +5,19 @@ 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 Psr\Log\LoggerInterface;
|
||||
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\Uid\Ulid;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
@@ -28,210 +25,69 @@ use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class LoginListener {
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
use GetTotpTrait;
|
||||
|
||||
private CacheItemPoolInterface $requestPool;
|
||||
private CacheItemPoolInterface $sessionPool;
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
ConfigBag $config,
|
||||
private Environment $twig,
|
||||
CacheItemPoolInterface $noncePool,
|
||||
CacheItemPoolInterface $requestPool,
|
||||
CacheItemPoolInterface $sessionPool,
|
||||
LoggerInterface $logger,
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
private DomainManager $domainManager,
|
||||
private LoginManager $loginManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->requestPool = new MonitorCacheKeys($requestPool);
|
||||
$this->sessionPool = new MonitorCacheKeys($sessionPool);
|
||||
$this->noncePool = $noncePool;
|
||||
$this->logger = $logger;
|
||||
$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) {
|
||||
/* if using token */
|
||||
if ($payload->token) {
|
||||
$response = $this->checkToken($payload, $event->getRequest());
|
||||
} else if ($this->config->staticSecret()) {
|
||||
$response = $this->checkPassword($payload);
|
||||
}
|
||||
}
|
||||
} 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(
|
||||
$payload ? $payload->toString() : $data,
|
||||
$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->noncePool->getItem($payload->nonce);
|
||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
|
||||
$this->noncePool->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;
|
||||
$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 : '')
|
||||
));
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function checkPassword(Payload $payload): ?Response {
|
||||
/* When using password but password is disabled, request will always fail. */
|
||||
|
||||
/* if password is correct */
|
||||
|
||||
if (hash_equals($this->config->staticSecret(), $payload->password)) {
|
||||
/* password is correct */
|
||||
|
||||
/* nonce *may* be client provided, but must still be unique */
|
||||
|
||||
/* if server/client nonce is acceptable (valid server or unused client) */
|
||||
$nonceItem = $this->noncePool->getItem($payload->nonce);
|
||||
if (($nonceItem->isHit() && $nonceItem->get()) || ! $nonceItem->isHit()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
|
||||
$this->noncePool->save($nonceItem);
|
||||
|
||||
/* password authentication successful, grant access and set response */
|
||||
$cleanId = $this->makeCacheKey($payload->id);
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
return new Response("hi $cleanId",
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/plain']
|
||||
);
|
||||
}
|
||||
}
|
||||
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->sessionPool->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->sessionPool->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->sessionPool->getItem($ipKey);
|
||||
$sessionIp->set($id);
|
||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
||||
$this->sessionPool->save($sessionIp);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function logFailure(string $data, Request $request): bool {
|
||||
// TODO use rate-limiting symfony system (also update RejectListener)
|
||||
$timeframe = (int)floor(time() / $this->getTotp()->getPeriod());
|
||||
/* hash the data and timeframe, so we do not count duplicates in the same timeframe
|
||||
* hitting refresh a few times should not lock you out */
|
||||
$ipKey = $this->makeCacheKey("ip_{$request->getClientIp()}");
|
||||
$failuresItem = $this->requestPool->getItem($ipKey);
|
||||
$failures = $failuresItem->get() ?? [];
|
||||
$failures[hash('xxh3', "$timeframe-$data")] = true;
|
||||
$limitReached = count($failures) >= $this->config->limit();
|
||||
$failuresItem->set($failures);
|
||||
$failuresItem->expiresAfter($limitReached
|
||||
? $this->config->limitTtl() : $this->config->limitTimeout()
|
||||
);
|
||||
$this->requestPool->save($failuresItem);
|
||||
return $limitReached;
|
||||
private function logFailure(Request $request): bool {
|
||||
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||
return ($limiter->consume(1)->getRemainingTokens() < 1);
|
||||
}
|
||||
|
||||
/** @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;
|
||||
@@ -244,6 +100,8 @@ final readonly class LoginListener {
|
||||
$answer = [
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $host,
|
||||
'username' => $username,
|
||||
];
|
||||
|
||||
if ($json) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
|
||||
final readonly class LookupListener {
|
||||
use GetTotpTrait;
|
||||
|
||||
public function __construct(
|
||||
ConfigBag $config,
|
||||
) {
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
#[AsEventListener(priority: 44)]
|
||||
public function onKernelResponse(ResponseEvent $event): void {
|
||||
/* if you enable a static secret, you may choose to also enable
|
||||
* a totp-lookup, which contains a special path, when successfully
|
||||
* authenticated, instead of returning "hi <your-name>" it returns
|
||||
* "code <totp-code>" */
|
||||
if ($this->config->staticSecret() && $this->config->lookupTotp()) {
|
||||
if (str_contains($event->getRequest()->getPathInfo(),
|
||||
$this->config->lookupTotp()) &&
|
||||
$event->getResponse()->getStatusCode() === Response::HTTP_OK
|
||||
) {
|
||||
$totp = $this->getTotp();
|
||||
$next = $totp->at(time() + $totp->getPeriod());
|
||||
$event->setResponse(new Response(
|
||||
"next {$next}",
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/plain']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,45 +4,44 @@ declare(strict_types=1);
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class RejectListener {
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $requestPool,
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
/** @throws SyntaxError|InvalidArgumentException|RuntimeError|LoaderError */
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
}
|
||||
|
||||
/** @throws SyntaxError|RuntimeError|LoaderError */
|
||||
#[AsEventListener(priority: 77)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||
|
||||
/* check if they have made too many failed login attempts */
|
||||
$failuresItem = $this->requestPool->getItem($ipKey);
|
||||
if ($failuresItem->isHit()) {
|
||||
$failures = $failuresItem->get() ?? [];
|
||||
if (count($failures) >= $this->config->limit()) {
|
||||
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
|
||||
$html = $this->twig->render('error.html.twig');
|
||||
$event->setResponse(new Response($html, ($this->config->teapot()
|
||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
||||
['Content-Type' => 'text/html']
|
||||
));
|
||||
}
|
||||
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
|
||||
if ($limiter->consume(0)->getRemainingTokens() < 1) {
|
||||
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
|
||||
$html = $this->twig->render('error.html.twig');
|
||||
$event->setResponse(new Response($html, ($this->config->teapot()
|
||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
||||
['Content-Type' => 'text/html']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-50
@@ -8,18 +8,20 @@ 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 IS_DIRTY = '__is_dirty';
|
||||
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;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(CacheItemPoolInterface $cache) {
|
||||
$this->cache = $cache;
|
||||
$items = $cache->getItems([self::KEY_LIST, self::IS_DIRTY]);
|
||||
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
||||
foreach ($items as $item) {
|
||||
if ( ! $item->isHit()) {
|
||||
$this->initialize();
|
||||
@@ -31,11 +33,11 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
/** @throws InvalidArgumentException */
|
||||
private function initialize(): void {
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$keyList->set([]);
|
||||
$isDirty->set(false);
|
||||
$changeList->set([]);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->saveDeferred($isDirty);
|
||||
$this->cache->saveDeferred($changeList);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
@@ -46,22 +48,24 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function isDirty(): bool {
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
return $isDirty->get() ?? false;
|
||||
public function getChanges(): array {
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
return $changeList->get() ?? [];
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function markClean(): void {
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
$isDirty->set(false);
|
||||
$this->cache->save($isDirty);
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$changeList->set([]);
|
||||
$this->cache->save($changeList);
|
||||
}
|
||||
|
||||
public function getItem(string $key): CacheItemInterface {
|
||||
return $this->cache->getItem($key);
|
||||
}
|
||||
|
||||
/** @return CacheItemInterface[]
|
||||
* @throws InvalidArgumentException */
|
||||
public function getItems(array $keys = []): iterable {
|
||||
return $this->cache->getItems($keys);
|
||||
}
|
||||
@@ -83,20 +87,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
public function deleteItem(string $key): bool {
|
||||
if ($key === self::KEY_LIST || $key === self::IS_DIRTY) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not delete the private key list or is dirty flag'
|
||||
);
|
||||
}
|
||||
$this->isValid($key);
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
$keyValues = $keyList->get();
|
||||
if (isset($keyValues[$key])) {
|
||||
unset($keyValues[$key]);
|
||||
$keyList->set($keyValues);
|
||||
$isDirty->set(true);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->saveDeferred($isDirty);
|
||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
@@ -104,25 +102,17 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
}
|
||||
|
||||
public function deleteItems(array $keys): bool {
|
||||
if (in_array(self::KEY_LIST, $keys, true) ||
|
||||
in_array(self::IS_DIRTY, $keys, true)
|
||||
) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not delete the private key list or is dirty flag'
|
||||
);
|
||||
}
|
||||
$this->allValid($keys);
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
$keyValues = $keyList->get();
|
||||
foreach ($keys as $key) {
|
||||
if (isset($keyValues[$key])) {
|
||||
unset($keyValues[$key]);
|
||||
$isDirty->set(true);
|
||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
||||
}
|
||||
}
|
||||
$keyList->set($keyValues);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->saveDeferred($isDirty);
|
||||
$this->cache->commit();
|
||||
|
||||
return $this->cache->deleteItems($keys);
|
||||
@@ -140,25 +130,48 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
return $this->cache->saveDeferred($item);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function update(CacheItemInterface $item) {
|
||||
if ($item->getKey() === self::KEY_LIST || $item->getKey() === self::IS_DIRTY) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not alter the private key list or is dirty flag'
|
||||
);
|
||||
}
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
$keyValues = $keyList->get();
|
||||
$keyValues[$item->getKey()] = true;
|
||||
$keyList->set($keyValues);
|
||||
$isDirty->set(true);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->saveDeferred($isDirty);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
public function commit(): bool {
|
||||
return $this->cache->commit();
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|OutOfBoundsException */
|
||||
private function update(CacheItemInterface $item): void {
|
||||
$this->isValid($item->getKey());
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$keyValues = $keyList->get();
|
||||
$keyValues[$item->getKey()] = true;
|
||||
$keyList->set($keyValues);
|
||||
$this->logChange($item->getKey());
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
/** @throws OutOfBoundsException */
|
||||
private function isValid(string $key): void {
|
||||
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not modify the private key or change lists'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws OutOfBoundsException */
|
||||
private function allValid(array $keys): void {
|
||||
if (in_array(self::KEY_LIST, $keys, true) ||
|
||||
in_array(self::CHANGE_LIST, $keys, true)
|
||||
) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not modify the private key or change lists'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void {
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$changeValues = $changeList->get();
|
||||
$changeValues[$key] = $code;
|
||||
$changeList->set($changeValues);
|
||||
$this->cache->saveDeferred($changeList);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-43
@@ -7,69 +7,49 @@ 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 $requestPool;
|
||||
private MonitorCacheKeys $persistRequestPool;
|
||||
private MonitorCacheKeys $sessionPool;
|
||||
private MonitorCacheKeys $persistSessionPool;
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
private MonitorCacheKeys $sessionStorage;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $requestPool,
|
||||
CacheItemPoolInterface $persistRequestPool,
|
||||
CacheItemPoolInterface $sessionPool,
|
||||
CacheItemPoolInterface $persistSessionPool
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
CacheItemPoolInterface $sessionStorage,
|
||||
) {
|
||||
$this->requestPool = new MonitorCacheKeys($requestPool);
|
||||
$this->persistRequestPool = new MonitorCacheKeys($persistRequestPool);
|
||||
$this->sessionPool = new MonitorCacheKeys($sessionPool);
|
||||
$this->persistSessionPool = new MonitorCacheKeys($persistSessionPool);
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function boot(): void {
|
||||
/* the caches are considered warm as soon as they are not empty */
|
||||
if (empty($this->requestPool->getKeys())) {
|
||||
$items = $this->persistRequestPool->getItems($this->persistRequestPool->getKeys());
|
||||
if (empty($this->sessionCache->getKeys())) {
|
||||
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
|
||||
foreach ($items as $item) {
|
||||
$this->requestPool->saveDeferred($item);
|
||||
$this->sessionCache->saveDeferred($item);
|
||||
}
|
||||
$this->requestPool->markClean();
|
||||
$this->requestPool->commit();
|
||||
}
|
||||
|
||||
if (empty($this->sessionPool->getKeys())) {
|
||||
$items = $this->persistSessionPool->getItems($this->persistSessionPool->getKeys());
|
||||
foreach ($items as $item) {
|
||||
$this->sessionPool->saveDeferred($item);
|
||||
}
|
||||
$this->sessionPool->markClean();
|
||||
$this->sessionPool->commit();
|
||||
$this->sessionCache->markClean();
|
||||
$this->sessionCache->commit();
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function persist(): void {
|
||||
/* we only need to persist the caches if they contain changes */
|
||||
if ($this->requestPool->isDirty()) {
|
||||
$this->requestPool->markClean();
|
||||
$items = $this->requestPool->getItems($this->requestPool->getKeys());
|
||||
$this->persistRequestPool->clear();
|
||||
/* we only need to persist the changes made to the cache (if any) */
|
||||
$changes = $this->sessionCache->getChanges();
|
||||
if ($changes) {
|
||||
$this->sessionCache->markClean();
|
||||
$items = $this->sessionCache->getItems(array_keys($changes));
|
||||
foreach ($items as $item) {
|
||||
$this->persistRequestPool->saveDeferred($item);
|
||||
if (($changes[$item->getKey()] ?? null) === MonitorCacheKeys::REMOVED) {
|
||||
$this->sessionStorage->deleteItem($item->getKey());
|
||||
} else {
|
||||
$this->sessionStorage->saveDeferred($item);
|
||||
}
|
||||
}
|
||||
$this->persistRequestPool->commit();
|
||||
}
|
||||
|
||||
if ($this->sessionPool->isDirty()) {
|
||||
$this->sessionPool->markClean();
|
||||
$items = $this->sessionPool->getItems($this->sessionPool->getKeys());
|
||||
$this->persistSessionPool->clear();
|
||||
foreach ($items as $item) {
|
||||
$this->persistSessionPool->saveDeferred($item);
|
||||
}
|
||||
$this->persistSessionPool->commit();
|
||||
$this->sessionStorage->commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,10 +7,16 @@ use App\ConfigBag;
|
||||
use OTPHP\Factory;
|
||||
use OTPHP\TOTPInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait GetTotpTrait {
|
||||
protected readonly ConfigBag $config;
|
||||
|
||||
#[Required]
|
||||
public function setConfig(ConfigBag $config): void {
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
protected function getTotp(): TOTPInterface {
|
||||
$otp = Factory::loadFromProvisioningUri(
|
||||
$this->config->totpUri(), $this->config->clock()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait HasLoggerTrait {
|
||||
protected readonly LoggerInterface $logger;
|
||||
|
||||
#[Required]
|
||||
public function setLogger(LoggerInterface $logger): void {
|
||||
$this->logger = $logger;
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,24 @@ namespace App\Trait;
|
||||
use Exception;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait MakeNonceTrait {
|
||||
/* 15 bytes neatly fits in base64 */
|
||||
private const NONCE_LENGTH = 15;
|
||||
private const NONCE_TTL = 120;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
protected readonly CacheItemPoolInterface $noncePool;
|
||||
protected readonly LoggerInterface $logger;
|
||||
/* 15 bytes neatly fits in base64 */
|
||||
private const int NONCE_LENGTH = 15;
|
||||
private const int NONCE_TTL = 120;
|
||||
|
||||
protected readonly CacheItemPoolInterface $nonceCache;
|
||||
|
||||
#[Required]
|
||||
public function setNonceCache(CacheItemPoolInterface $nonceCache): void {
|
||||
$this->nonceCache = $nonceCache;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|Exception */
|
||||
protected function makeNonce(int $retries = 3): string {
|
||||
@@ -24,7 +31,7 @@ trait MakeNonceTrait {
|
||||
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
||||
static::NONCE_LENGTH
|
||||
)), '+/', '-_'), '=');
|
||||
$nonceItem = $this->noncePool->getItem($nonce);
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
|
||||
|
||||
if ($nonceItem->isHit()) {
|
||||
if ($retries < 1) {
|
||||
@@ -41,7 +48,7 @@ trait MakeNonceTrait {
|
||||
$nonceItem->set(true); /* valid */
|
||||
$nonceItem->expiresAfter(static::NONCE_TTL);
|
||||
$this->logger->debug("added nonce: $nonce");
|
||||
$this->noncePool->save($nonceItem);
|
||||
$this->nonceCache->save($nonceItem);
|
||||
return $nonce;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
0
|
||||
__key_list
|
||||
a:0:{}
|
||||
@@ -1,3 +0,0 @@
|
||||
0
|
||||
__is_dirty
|
||||
b:0;
|
||||
@@ -1,3 +0,0 @@
|
||||
0
|
||||
__key_list
|
||||
a:0:{}
|
||||
@@ -1,3 +0,0 @@
|
||||
0
|
||||
__is_dirty
|
||||
b:0;
|
||||
@@ -1,3 +0,0 @@
|
||||
1766103213
|
||||
suppress
|
||||
N;
|
||||
@@ -1,3 +0,0 @@
|
||||
32503594112
|
||||
totp
|
||||
s:138:"otpauth://totp/Preauth-TOTP?secret=5RL5FOJGV4XRKGT74ZVN4725OAM244SU7JYXYX4SHQDTJI4P3YKBYAFUVBBOCLI5XSOLERNB6IQQ54SIGY6QHJ26JM4OP3ZJVBUUBIY";
|
||||
@@ -0,0 +1,84 @@
|
||||
<script>
|
||||
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();
|
||||
|
||||
{# 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 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,9 +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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-116
@@ -3,123 +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>
|
||||
{% if env.allow_password %}
|
||||
<div class="right boxTk">
|
||||
<label for="preauth-token">{{ env.token_name }}:</label><br>
|
||||
<span id="preauth-use-pw">🔃 {{ env.password_name }}</span></div>
|
||||
<div class="boxTk"><input type="text" name="preauth_token" id="preauth-token"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
|
||||
<div class="right boxPw hidden">
|
||||
<label for="preauth-password">{{ env.password_name }}:</label><br>
|
||||
<span id="preauth-use-tk">🔃 {{ env.token_name }}</span></div>
|
||||
<div class="boxPw hidden"><input type="password" name="preauth_password" id="preauth-password"
|
||||
autocomplete="password" disabled="disabled" required="required"></div>
|
||||
{% else %}
|
||||
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div>
|
||||
<div><input type="text" name="preauth_token" id="preauth-token"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
{% endif %}
|
||||
<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>
|
||||
<script>
|
||||
const form = document.getElementById('preauth-form');
|
||||
const message = document.getElementById('preauth-message');
|
||||
|
||||
{% if env.allow_password %}
|
||||
const usePw = document.getElementById('preauth-use-pw');
|
||||
const useTk = document.getElementById('preauth-use-tk');
|
||||
const boxPw = document.querySelectorAll('.boxPw');
|
||||
const boxTk = document.querySelectorAll('.boxTk');
|
||||
|
||||
usePw.addEventListener('click', (event) => {
|
||||
form.preauth_token.value = '';
|
||||
form.preauth_token.disabled = true;
|
||||
form.preauth_password.disabled = false;
|
||||
boxTk.forEach((element) => {
|
||||
element.classList.add('hidden');
|
||||
});
|
||||
boxPw.forEach((element) => {
|
||||
element.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
useTk.addEventListener('click', (event) => {
|
||||
form.preauth_password.value = '';
|
||||
form.preauth_password.disabled = true;
|
||||
form.preauth_token.disabled = false;
|
||||
boxPw.forEach((element) => {
|
||||
element.classList.add('hidden');
|
||||
});
|
||||
boxTk.forEach((element) => {
|
||||
element.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
{% if not post ?? false %}
|
||||
{{- include('_script.html.twig') -}}
|
||||
{% endif %}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
{% if env.allow_password %}
|
||||
/* make base64url string containing our payload json object */
|
||||
/* payload will contain either token or password */
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.preauth_id.value,
|
||||
...( form.preauth_token.value && { token: form.preauth_token.value }),
|
||||
...(( ! form.preauth_token.value) && { password: form.preauth_password.value }),
|
||||
nonce: form.preauth_nonce.value,
|
||||
json: true
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
{% else %}
|
||||
/* make base64url string containing our payload json object */
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.preauth_id.value,
|
||||
token: form.preauth_token.value,
|
||||
nonce: form.preauth_nonce.value,
|
||||
json: true
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
{% endif %}
|
||||
|
||||
/* send our request to the server */
|
||||
fetch(window.location.href, {
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user