23 Commits
Author SHA1 Message Date
andrew 3c1253ee45 Merge branch 'develop'
Push Docker / docker (push) Successful in 4m47s
Push Develop / docker (push) Successful in 4m44s
Sync GitHub / sync (push) Successful in 6s
2026-06-17 13:27:48 -04:00
andrew 70bf811b1d gitea action cleanup
Push Develop / docker (push) Successful in 6m49s
Sync GitHub / sync (push) Successful in 7s
2026-06-17 10:55:26 -04:00
andrew 3269151e9b new workflow to build and push to docker hub
Sync GitHub / sync (push) Failing after 6s
2026-06-14 18:27:32 -04:00
andrew 1da2188bdf Gitea action to sync to GitHub
Sync GitHub / sync (push) Successful in 7s
2026-06-13 21:28:59 -04:00
andrew 7cf7e04d17 extracted interfaces for services, to aid in creating tests 2026-06-05 15:15:21 -04:00
andrew 0813323ac2 renamed form fields to work better with password managers; fixed bug where an invalid login requests were not being counted as login attempts; preserve username when using central auth 2026-06-01 16:28:57 -04:00
andrew 9114cfd96f update to php 8.5, backup codes, etc.
modified:   Dockerfile
	modified:   composer.json
	modified:   composer.lock
	modified:   config/packages/twig.yaml
	modified:   config/services.yaml
	modified:   docs/Caddyfile
	modified:   docs/compose.yaml
	renamed:    docs/env.example -> docs/example.env
	modified:   public/index.php
	modified:   readme.md
	modified:   src/Command/GenerateBackupCodesCommand.php
	modified:   src/ConfigBag.php
	modified:   src/Data/Payload.php
	modified:   src/Enum/Scope.php
	modified:   src/Listener/AcceptListener.php
	modified:   src/Listener/AllowListener.php
	modified:   src/Listener/InterceptListener.php
	modified:   src/Listener/LoginListener.php
	modified:   src/MonitorCacheKeys.php
	modified:   src/PersistCache.php
	modified:   src/Service/BackupCodeManager.php
	modified:   src/Service/DomainManager.php
	new file:   src/Service/LoginManager.php
	modified:   src/Trait/CookieNameTrait.php
	modified:   src/Trait/GetTotpTrait.php
	modified:   src/Trait/MakeNonceTrait.php
	modified:   src/Trait/StringTrait.php
	modified:   src/Utilities.php
	modified:   templates/_script.html.twig
	modified:   templates/_style.html.twig
	modified:   templates/base.html.twig
	modified:   templates/login.html.twig
2026-05-29 21:56:42 -04:00
andrew 43e9b7136e auth subdomain tentatively complete.
All domain logic moved into service.
2026-05-22 12:43:02 -04:00
andrew 38124ef66c First draft of backup codes. only created when the command is called. no command yet to expire/review codes.
Also added a few safeguards against excessively long user input.

Started on ability to redirect to auth subdomain (incomplete).
2026-05-21 12:15:35 -04:00
andrew 6ed1ab26f1 cache persistence improvement, only update keys which have changed. More efficient, and less likely to cause race conditions. 2026-03-12 17:15:49 -04:00
andrew a0dc1a6049 rate-limiting update, now using a compound sliding-window. Continuing to move over to using traits more, and other code cleanup. 2026-03-11 08:27:27 -04:00
andrew 3d28485921 Removal of static-secret and totp-lookup.
Intending to build support for single-use backup codes.

Started refactoring to move trait dependencies internally, so that classes only have to specify their own direct dependencies.
2026-03-09 16:22:32 -04:00
andrew 27394ae555 v0.6.0 optional lookup for totp by static password 2026-02-10 13:31:09 -05:00
andrew c235aad941 single column if narrow enough 2026-01-17 14:02:23 -05:00
andrew b61400085a frontend support for password 2026-01-17 10:57:57 -05:00
andrew 4a543f45ca minor nonce related cleanup 2026-01-16 12:17:18 -05:00
andrew 4cec5a5963 added project history 2025-12-28 11:14:59 -05:00
andrew c3fbb12842 updating readme 2025-12-28 10:38:46 -05:00
andrew ac817649ab fixed bug which can occur if you delete cache files.
fixed warnings due to volume locations.
2025-12-26 21:17:36 -05:00
andrew 1c4c289d81 massive rewrite to switch to using listeners instead of controller, header for login payload instead of get request, removed icon system, asset system, was able to remove all the domain processing, enhanced cookie security, and more. 2025-12-23 11:24:47 -05:00
andrew b36aabb8a3 WIP: nearly done switching to using header instead of url parameters 2025-12-18 19:20:50 -05:00
andrew f486ab7481 already accepted/rejected are now listeners to start cleaning up the massive controller of doom 2025-12-17 15:25:07 -05:00
andrew a73d039e10 removed maker-bundle, switching to apcu 2025-12-16 22:34:57 -05:00
60 changed files with 2425 additions and 1877 deletions
-16
View File
@@ -1,16 +0,0 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
-13
View File
@@ -1,13 +0,0 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=f88a1074691c40415be4439345b79f69
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
+34
View File
@@ -0,0 +1,34 @@
name: Push Develop
on:
push:
branches:
- 'main'
- 'develop'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:develop
+34
View File
@@ -0,0 +1,34 @@
name: Push Docker
on:
push:
tags:
- '*.*.*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:latest
${{ vars.DOCKERHUB_TARGET }}:${{ github.ref_name }}
+37
View File
@@ -0,0 +1,37 @@
name: Sync GitHub
on:
push:
branches:
- '**'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config --global user.name "Andrew Sync"
git config --global user.email "sync@digitaladapt.com"
- name: Add GitHub Remote
env:
SYNC_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
SYNC_TARGET: ${{ vars.SYNC_GITHUB_TARGET }}
run: |
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
- name: Push Current Branch
run: |
git push github HEAD:${GITHUB_REF_NAME}
- name: Push Tags
run: |
git push github --tags
+2 -4
View File
@@ -1,10 +1,8 @@
/.idea/
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
-1
View File
@@ -5,4 +5,3 @@ php {
root /app/public
worker index.php
}
+43 -19
View File
@@ -1,45 +1,69 @@
# use build image, to avoid needing composer in final image
FROM composer AS build
# use build image, to simplify final image
FROM php:8.5-trixie AS build
# install APCu and composer
RUN pecl install apcu && \
docker-php-ext-enable apcu
COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apt-get update && \
apt-get install -y unzip git
# symfony required environment variables
ENV APP_ENV=prod
ENV APP_DEBUG=0
ENV DEFAULT_URI='http://'
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
# load application info build image
RUN mkdir -p /app
# load application into build image
RUN mkdir -p /data/preauth
RUN mkdir -p /app/bin
WORKDIR /app
COPY . /app/
COPY ./bin/console /app/bin/console
COPY ./config /app/config
COPY ./public /app/public
COPY ./src /app/src
COPY ./templates /app/templates
COPY ./composer.json /app/composer.json
COPY ./composer.lock /app/composer.lock
COPY ./symfony.lock /app/symfony.lock
# install dependencies
# install application dependencies
RUN composer install --no-dev --optimize-autoloader
RUN composer dump-env prod --empty
RUN php bin/console cache:clear
# start creating final image
FROM dunglas/frankenphp:php8.4-trixie
FROM dunglas/frankenphp:php8.5-trixie
# install APCu
RUN pecl install apcu && \
docker-php-ext-enable apcu
# symfony required environment variables
ENV APP_ENV=prod
ENV APP_DEBUG=0
ENV DEFAULT_URI='http://'
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
# load application into final image
WORKDIR /app
COPY --from=build /data/preauth /data/preauth
COPY --from=build /app /app
# configure container
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
# install APCu
RUN pecl install apcu && \
docker-php-ext-enable apcu --ini-name 10-docker-php-ext-apcu.ini
VOLUME ["/app/var"]
# app uses var folder for cache storage
VOLUME ["/config", "/data"]
# runs http on standard port
EXPOSE 80
HEALTHCHECK --interval=5m --retries=3 --start-interval=1s --start-period=10s --timeout=2s \
# healthcheck
HEALTHCHECK --interval=5m \
--retries=3 \
--start-interval=1s \
--start-period=10s \
--timeout=2s \
CMD curl http://localhost || exit 1
Executable
BIN
View File
Binary file not shown.
+11 -15
View File
@@ -1,17 +1,13 @@
#!/bin/sh
# TODO make a Dockerfile which starts with this image as our base,
# add APCu, and our code...
# absolute path to our parent folder
project_dir=$(readlink -f "$0" | xargs dirname | xargs dirname)
echo '--- clearing the cache ------------------------------------------'
"$project_dir/bin/console" cache:clear
echo '--- starting up docker ------------------------------------------'
docker run \
-e FRANKENPHP_CONFIG="worker ./public/index.php" \
-e APP_RUNTIME=Runtime\\FrankenPhpSymfony\\Runtime \
-v /home/andrew/code/new-preauth/preauth:/app \
-p 80:80 -p 443:443 -p 443:443/udp \
dunglas/frankenphp
docker container rm preauth
docker build . -t digtialadapt/preauth:dev
docker run --name preauth \
-e APP_ENV=dev \
-e APP_DEBUG=true \
-e APP_SECRET=f88a1074691c40415be4439345b79f69 \
-e APP_SHARE_DIR=var/share \
-e DEFAULT_URI=http://localhost \
-v ./var/share:/app/var/share \
-p 8000:80 \
digtialadapt/preauth:dev
+7 -15
View File
@@ -1,21 +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/dotenv": "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.*",
@@ -35,11 +35,6 @@
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
@@ -73,8 +68,5 @@
"allow-contrib": false,
"require": "7.4.*"
}
},
"require-dev": {
"symfony/maker-bundle": "^1.65"
}
}
Generated
+413 -655
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2,6 +2,5 @@
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];
+8 -33
View File
@@ -1,40 +1,15 @@
framework:
cache:
app: cache.adapter.filesystem
pools:
nonceCache:
adapters: cache.adapter.apcu
rateLimitCache:
adapters: cache.adapter.apcu
sessionCache:
adapters:
# TODO FIXME install APCu so we can use that...
- cache.adapter.array
#- cache.adapter.apcu
requestCache:
adapters:
- cache.adapter.array
#- cache.adapter.apcu
persistSessionCache:
adapters:
- cache.adapter.filesystem
persistRequestCache:
adapters:
- cache.adapter.filesystem
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
-9
View File
@@ -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
+15
View 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 -5
View File
@@ -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:
+1 -4
View File
@@ -4,13 +4,9 @@ twig:
globals:
env:
title: '%env(TITLE)%'
icons: '%env(ICONS)%'
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'
id_name: '%env(ID_NAME)%'
token_name: '%env(TOKEN_NAME)%'
submit_name: '%env(SUBMIT_NAME)%'
@@ -19,3 +15,4 @@ twig:
teapot_message: '%env(TEAPOT_MESSAGE)%'
too_many_title: '%env(TOO_MANY_TITLE)%'
too_many_message: '%env(TOO_MANY_MESSAGE)%'
debug: '%env(SHELL_VERBOSITY)%'
+404 -413
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error
+29 -33
View File
@@ -8,44 +8,38 @@
# 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_'
# allow files in /app/public/assets directory to be served, false to disable
env(ASSETS): '1' # default enabled, boolean
# --- 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
# url to send people to, if they directly visit the preauth login page
env(SEND_TO): '' # default disabled
# if desired, in addition to supporting a TOTP, you can set a static password
env(STATIC_SECRET): '' # default disabled
# what subdomain to use for preauth login, only applies if using a domain
env(SUBDOMAIN): 'preauth'
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
# --- 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(ICONS): '0' # default disabled, boolean, use favicon from root domain
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(SUBMIT_NAME): 'Submit'
@@ -57,20 +51,22 @@ parameters:
env(TOO_MANY_TITLE): 'Too many requests'
env(TOO_MANY_MESSAGE): 'Try again later'
# --- 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.limit: '%env(LIMIT)%'
app.limit_timeout: '%env(LIMIT_TIMEOUT)%'
app.limit_ttl: '%env(LIMIT_TTL)%'
app.query_prefix: '%env(QUERY_PREFIX)%'
app.totp_uri: '%env(TOTP_URI)%'
app.subdomain_redirect: '%env(SUBDOMAIN_REDIRECT)%'
app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%'
app.assets: '%env(ASSETS)%'
app.ip_ttl: '%env(IP_TTL)%'
app.send_to: '%env(SEND_TO)%'
app.static_secret: '%env(STATIC_SECRET)%'
app.subdomain: '%env(SUBDOMAIN)%'
app.teapot: '%env(TEAPOT)%'
app.error_message: '%env(ERROR_MESSAGE)%'
app.teapot_title: '%env(TEAPOT_TITLE)%'
app.too_many_title: '%env(TOO_MANY_TITLE)%'
services:
# default configuration for services in *this* file
_defaults:
+20 -34
View File
@@ -1,42 +1,28 @@
# publically serve preauth, for login page
# TODO replace example.com with your domain
preauth.example.com {
reverse_proxy preauth {
header_up X-Forwarded-Uri {uri}
}
}
# if using caddy v2.9.x+ you can use this snippet
# snippet to put the pre-auth system in front any service easily
(preauth) {
reverse_proxy {args[0]} preauth {
method GET
header_up X-Forwarded-Uri {uri}
@preauth_ok status 2xx
handle_response @preauth_ok {
copy_response_headers {
include Set-Cookie Location
}
{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
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
}
+6 -7
View File
@@ -1,7 +1,7 @@
services:
preauth:
env_file:
# TODO rename ".env.example" to just ".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,14 +10,13 @@ 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:/app/var
- preauth-config:/config
- preauth-data:/data
volumes:
preauth:
preauth-config:
preauth-data:
+22 -32
View File
@@ -1,4 +1,4 @@
# --- Main Options ---
# --- main options ---
# URI containing secret and config for TOTP, which determines the token to login
# app will generate one, if not provided, but you should copy it to your .env file
@@ -8,47 +8,34 @@
# 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 ---
# query parameter prefix to prevent collisions with protected app
#QUERY_PREFIX='_preauth_'
# TODO make it so boolean options can be true/false
# allow files in /app/public/assets directory to be served, false to disable
#ASSETS=true # default enabled, boolean
# --- 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
# url to send people to, if they directly visit the preauth login page
#SEND_TO='' # default disabled
# if desired, in addition to supporting a TOTP, you can set a static password
#STATIC_SECRET='' # deafult disabled
# what subdomain to use for preauth login, only applies if using a domain
#SUBDOMAIN='preauth'
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
# --- 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'
#ICONS=false # default disabled, boolean, use favicon from root domain
#BG_COLOR='#029386' # teal
#FG_COLOR='#ffffff' # white
#ERROR_COLOR='#ffb16d' # apricot (light orange)
@@ -63,3 +50,6 @@
#TOO_MANY_TITLE='Too many requests'
#TOO_MANY_MESSAGE='Try again later'
# --- debug options ---
#SHELL_VERBOSITY=0 # set to "3" to log debug
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
use App\Kernel;
+51 -10
View File
@@ -1,27 +1,68 @@
# Preauth
Pre-authorization, because sometimes you need both a belt and suspenders.
For when you want to expose a web service without letting the whole world try to access it. Because sometimes you want both a belt and suspenders.
The goal of this is to make it as simple as possible to put a web service behind an extra layer of authentication.
I found myself needing to make my personal Nextcloud instance available outside my VPN, but was worried since it has had authentication exploits in the past.
Maybe you need the extra protection because it's a very sensitive system, or because it's a legacy system with known security issues.
So, I built a simple authentication gateway, which eventually turned into this project.
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
## Requirements
* Docker
* Caddy (as a reverse proxy)
* a domain
* a web service you want to secure
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
There is an example Caddyfile in /docs/ and example .env file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
There is an example Caddyfile in /docs/ and env.example file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
Preauth will need a subdomain on the same domain as the service it's securing, the default is "preauth", but you can use whatever you want.
When someone tries to reach your protected web service, Caddy will check with preauth if they are allowed, if their preauth cookie is missing, invalid, or expired, we will show them to a login screen.
When someone tries to reach your protected web service, Caddy will check with preauth if they are allowed, if their preauth cookie is missing, invalid, or expired, we will redirect them to a login screen.
I say login, but it's really just a TOTP code (6-digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be 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 redirected to the protected service.
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.
First time you spin up the docker container it will generate an encryption key for session storage, and the TOTP secret (which you'll load into your authenticator app).
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
Be sure to save those and add them to the containers environment, or it will generate new values every time it restarts.
## 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.
#### v0.4.0 (Dec 26th, 2025)
Massive rewrite to switch to using listeners instead of controller, header for login payload instead of get request, removed icon system, asset system, was able to remove all the domain processing, enhanced cookie security, and more.
#### v0.3.0 (Dec 15th, 2025)
Includes significant breaking changes.
Default port and transportation changed to http via port 80.
Names of environment variables have changed.
#### v0.2.0 (Dec 3rd, 2025)
Now with login rate limiting.
New page for client error (too many requests).
Made example docker compose.
#### v0.1.0 (Nov 14th, 2025)
Now an actual project, docker image pushed to docker hub, which uses php-fpm, code into a src folder, templates into separate files.
#### v0.0.1 (June 26th, 2024)
Started off as a single file script which was part of my caddy config. Hardcoded TOTP secret, zero flexibility, but functional. Would stay like that, quietly working in production for about a full year before any real change.
+1 -5
View File
@@ -7,12 +7,8 @@ use DateTimeImmutable;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
/* A new Clock should be made for each request,
* because a Clock is a snapshot of when the request was made. */
#[AsAlias(ClockInterface::class)]
class Clock implements ClockInterface {
private DateTimeImmutable $date;
final readonly class Clock implements ClockInterface {
public function now(): DateTimeImmutable {
return new DateTimeImmutable();
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\PersistCache;
use App\Service\BackupCodeInterface;
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 BackupCodeInterface $manager,
private readonly PersistCache $persistCache,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('app:generate-backup-codes');
$this->setDescription('Generate singleuse backup codes')
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
}
/** @throws InvalidArgumentException */
protected function execute(InputInterface $input, OutputInterface $output): int {
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
$this->persistCache->boot();
$count = (int) $input->getArgument('count');
$codes = $this->manager->generate($count);
foreach ($codes as $code) {
$output->writeln($code);
}
$this->persistCache->persist();
return Command::SUCCESS;
}
}
+35 -69
View File
@@ -3,54 +3,40 @@ declare(strict_types=1);
namespace App;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ConfigBag {
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 ?string $assetsDir;
private ?int $ipTtl;
private ?string $sendTo;
private ?string $staticSecret;
private ?string $subdomain;
private bool $teapot;
private string $errorMessage;
private string $teapotTitle;
private string $tooManyTitle;
/** @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.assets%')] bool $assets,
#[Autowire('%kernel.project_dir%/public/assets/')] string $assetsDir,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.send_to%')] ?string $sendTo,
#[Autowire('%app.static_secret%')] ?string $staticSecret,
#[Autowire('%app.subdomain%')] ?string $subdomain,
#[Autowire('%app.teapot%')] bool $teapot,
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->assetsDir = $assets ? $assetsDir : null;
$this->ipTtl = $ipTtl ?: null;
$this->sendTo = $sendTo ?: null;
$this->staticSecret = $staticSecret ?: null;
$this->subdomain = $subdomain ?: 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->tooManyTitle = $tooManyTitle;
}
public function clock(): ClockInterface {
@@ -61,47 +47,27 @@ 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;
}
public function assetsDir(): ?string {
return $this->assetsDir;
}
public function ipTtl(): ?int {
return $this->ipTtl;
}
public function sendTo(): ?string {
return $this->sendTo ?: null;
}
public function staticSecret(): ?string {
return $this->staticSecret;
}
public function subdomain(): ?string {
return $this->subdomain;
}
public function teapot(): bool {
return $this->teapot;
}
public function errorMessage(): string {
return $this->errorMessage;
}
public function teapotTitle(): string {
return $this->teapotTitle;
}
public function tooManyTitle(): string {
return $this->tooManyTitle;
}
}
View File
-218
View File
@@ -1,218 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\ConfigBag;
use App\MonitorCacheKeys;
use App\Utilities;
use OTPHP\Factory;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Ulid;
final class PreauthController extends AbstractController {
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
#[Route(path: '/{path<.+>?}', name: 'preauth', priority: 99)]
public function index(
LoggerInterface $logger,
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $requestCache,
ConfigBag $config,
Utilities $utilities,
Request $request,
?string $path
): Response {
/* if they have requested a file we have in our asset directory, then serve it,
* but only if we are on the preauth domain, filenames are limited to safe
* characters (we allow alphanumeric and "- _ . /" without "..") */
if ($path && $config->assetsDir()) {
/* host matches preauth, so we are allowed to serve assets */
if ($utilities->buildDomain($config->subdomain(), $request->getHost()) ===
$request->getHost()
) {
$cleanPath = $utilities->cleanPath($config->assetsDir() . $path);
if ($cleanPath && file_exists($cleanPath)) {
$logger->debug("sending static asset: $path");
return $this->file(
$cleanPath,
disposition: ResponseHeaderBag::DISPOSITION_INLINE
);
}
}
}
/* we must monitor keys, so we can enable persistence */
$requestCache = new MonitorCacheKeys($requestCache);
$sessionCache = new MonitorCacheKeys($sessionCache);
// TODO need an unblock option, thinking a command, so you can do
// docker exec -it preauth preauth list-blocked and
// docker exec -it preauth preauth unblock <ip-address>
// TODO i think I can also move serve static asset into a kernel listener...
// TODO move already-logged-in and already-blocked into a pre-controller
// kernel event listener
// https://symfony.com/doc/7.3/event_dispatcher.html
// #before-filters-with-the-kernel-controller-event
/* check if they sent the preauth cookie */
if ($request->cookies->has($config->query('ulid'))) {
if ($sessionCache->hasItem(
'cookie_' . $request->cookies->get($config->query('ulid'))
)) {
/* cookie sent corresponds valid existing session */
$id = $sessionCache->getItem(
'cookie_' . $request->cookies->get($config->query('ulid'))
)->get();
$logger->debug("has valid cookie-session: $id");
return new Response("hi $id");
}
}
$ipKey = $utilities->makeCacheKey("ip_{$request->getClientIp()}");
/* check if they came from allowed ip address */
if ($sessionCache->hasItem($ipKey)) {
/* request sent from ip with valid existing session */
$id = $sessionCache->getItem($ipKey)->get();
$logger->debug("has valid ip-session: $id");
return new Response("hi $id");
}
/* check if they have made too many failed login attempts */
$failuresItem = $requestCache->getItem($ipKey);
if ($failuresItem->isHit()) {
$failures = $failuresItem->get();
if (count($failures) >= $config->limit()) {
$logger->debug("already blocked: {$request->getClientIp()}");
return $this->render('error.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
], new Response(status: $config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS
));
}
}
// TODO move monitor cache to here... because everything above is readonly...
// TODO has-login-query-fields could be another pre-controller kernel event listener
/* check if they are attempting to log in right now */
if ($request->query->has($config->query('id')) &&
$request->query->has($config->query('token'))
) {
$otp = Factory::loadFromProvisioningUri($config->totpUri(), $config->clock());
/* they gave us a token, but it does not match totp nor static secret */
if ( ! $otp->verify($request->query->get($config->query('token'))) &&
( ! $config->staticSecret() ||
$request->query->get($config->query('token')) !== $config->staticSecret()
)
) {
/* hash the query, so we do not count duplicates
* hitting refresh a few times should not lock you out */
$failuresItem = $requestCache->getItem($ipKey);
$failures = $failuresItem->get() ?? [];
$failures[$utilities->hash($request->query)] = true;
$failuresItem->set($failures);
$failuresItem->expiresAfter(($failures >= $config->limit())
? $config->limitTtl() : $config->limitTimeout()
);
$requestCache->save($failuresItem);
if (count($failures) >= $config->limit()) {
$logger->debug("starting to block now: {$request->getClientIp()}");
return $this->render('error.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
], new Response(status: $config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS
));
}
$logger->debug("login failure: {$request->getClientIp()}");
return $this->render('login.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
'return_value' => $request->query->get($config->query('return')) ?:
"{$request->getPathInfo()}{$request->getQueryString()}",
'has_error' => true,
], new Response(status: Response::HTTP_UNAUTHORIZED));
}
/* successful auth with token, store session and set the cookie */
$cleanId = $utilities->makeCacheKey($request->query->get($config->query('id')));
$ulid = new Ulid();
$sessionCookie = $sessionCache->getItem($utilities->makeCacheKey("cookie_$ulid"));
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$logger->error("successful login but ULID collision aborting");
// TOOD maybe a pretty error page
return new Response('Internal Server Error',
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
$sessionCookie->set($cleanId);
$sessionCookie->expiresAfter($config->cookieTtl());
$sessionCache->saveDeferred($sessionCookie);
if ($config->ipTtl()) {
$sessionIp = $sessionCache->getItem($ipKey);
$sessionIp->set($cleanId);
$sessionIp->expiresAfter($config->ipTtl());
$sessionCache->saveDeferred($sessionIp);
}
$sessionCache->commit();
/* successful login, reset rate-limit of their ip */
$requestCache->deleteItem($ipKey);
$redirect = $request->query->get($config->query('return')) ?: $config->sendTo();
if ( ! $redirect) {
/* just logged in with nowhere to go */
$response = new Response("hi $cleanId");
$response->headers->setCookie(Cookie::create(
$config->query('ulid'), $ulid->toString(),
time() + $config->cookieTtl(),
domain: $utilities->baseDomain($request->getHost()), secure: true
));
$logger->debug("successful login with no return set: $cleanId");
return $response;
}
$response = $this->redirect($redirect);
$response->headers->setCookie(Cookie::create(
$config->query('ulid'), $ulid->toString(),
time() + $config->cookieTtl(),
domain: $utilities->baseDomain($request->getHost()), secure: true
));
$logger->debug("successful login redirecting back: $cleanId");
return $response;
}
/* host matches where we would send them so show the preauth login page */
if ($utilities->buildDomain($config->subdomain(), $request->getHost()) ===
$request->getHost()
) {
$logger->debug("presenting login page: {$request->getClientIp()}");
return $this->render('login.html.twig', [
'base_domain' => $utilities->baseDomain($request->getHost()),
'return_value' => $request->query->get($config->query('return')) ?:
"{$request->getPathInfo()}{$request->getQueryString()}",
]);
}
/* needs to be sent to the login page */
$query = http_build_query([
$config->query('return') => $request->getUri(),
]);
$logger->debug("elsewhere sending to login: {$request->getClientIp()}");
$destination = $utilities->buildDomain($config->subdomain(), $request->getHost());
return $this->redirect("https://$destination/?$query");
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
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 */
final class Payload {
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 */
$json = base64_decode(str_pad(strtr($base64url, '-_', '+/'),
strlen($base64url) % 4, '='
), true);
if ($json) {
/* convert the json string into real data */
$data = json_decode($json);
if (is_object($data)) {
return Payload::create($data);
}
}
return null;
}
public static function load(InputBag $input): ?Payload {
/* convert form data into real data */
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[
'id' => $input->get('username'),
'nonce' => $input->get('nonce'),
'token' => $input->get('totp'),
'json' => false,
]);
}
return null;
}
public static function create(object $data): ?Payload {
/* if missing required fields id, nonce, or token */
if (strlen(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 = mb_substr(trim($data->id), 0, 128);
$payload->nonce = mb_substr(trim($data->nonce), 0, 128);
$payload->json = ($data->json ?? true);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = mb_substr(trim($data->token), 0, 128);
return Payload::constrict($payload);
}
public function toString(): string {
return json_encode($this);
}
private static function constrict(Payload $payload): Payload {
/* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) {
$payload->json = false;
}
return $payload;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
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';
case None = 'none';
}
+4 -1
View File
@@ -3,16 +3,18 @@ declare(strict_types=1);
namespace App;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel {
final class Kernel extends BaseKernel {
use MicroKernelTrait;
private PersistCache $persistCache;
/** @throws InvalidArgumentException */
public function boot(): void {
parent::boot();
@@ -20,6 +22,7 @@ class Kernel extends BaseKernel {
$this->persistCache->boot();
}
/** @throws InvalidArgumentException */
public function terminate(Request $request, Response $response): void {
$this->persistCache->persist();
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
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 $sessionCache,
private DomainInterface $domainManager,
) {}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 99)]
public function onKernelRequest(RequestEvent $event): void {
/* 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 ($cookie && $this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */
$id = $this->sessionCache->getItem($cookieKey)->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
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 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 $sessionCache,
private ConfigBag $config,
) {}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 88)]
public function onKernelRequest(RequestEvent $event): void {
if ($this->config->ipTtl() > 0) {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
if ($this->sessionCache->hasItem($ipKey)) {
/* ip address corresponds to valid existing session */
$id = $this->sessionCache->getItem($ipKey)->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Service\DomainInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class InterceptListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
public function __construct(
private ConfigBag $config,
private DomainInterface $domainManager,
private Environment $twig,
) {}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
#[AsEventListener(priority: 55)]
public function onKernelRequest(RequestEvent $event): void {
/* 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(),
]);
$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;
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Data\Payload;
use App\Service\DomainInterface;
use App\Service\LoginInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class LoginListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
) {
$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);
} 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;
}
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;
}
}
/* login attempted but unsuccessful, log and block if needed */
$limitReached = $this->logFailure($event->getRequest());
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
));
}
private function logFailure(Request $request): bool {
$limiter = $this->rateLimiter->create($request->getClientIp());
return ($limiter->consume(1)->getRemainingTokens() < 1);
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
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;
$message = $this->config->teapot() ? $this->config->teapotTitle()
: $this->config->tooManyTitle();
} else {
$status = Response::HTTP_UNAUTHORIZED;
$message = $this->config->errorMessage();
}
$answer = [
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
];
if ($json) {
$contentType = 'application/json';
$content = json_encode($answer);
} else {
$contentType = 'text/html';
$content = $this->twig->render('login.html.twig', $answer);
}
return new Response($content, $status, ["Content-Type" => $contentType]);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
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;
private RateLimiterFactoryInterface $rateLimiter;
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 {
/* check if they have made too many failed login attempts */
$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']
));
}
}
}
+72 -59
View File
@@ -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. */
class MonitorCacheKeys implements CacheItemPoolInterface {
private const KEY_LIST = '__key_list';
private const IS_DIRTY = '__is_dirty';
/* 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 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 we sanitize cache keys, to prevent this */
/** @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();
@@ -28,40 +30,42 @@ class MonitorCacheKeys implements CacheItemPoolInterface {
}
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @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();
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function getKeys(): array {
$keyList = $this->cache->getItem(self::KEY_LIST);
return array_keys($keyList->get() ?? []);
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
public function isDirty(): bool {
$isDirty = $this->cache->getItem(self::IS_DIRTY);
return $isDirty->get() ?? false;
/** @throws InvalidArgumentException */
public function getChanges(): array {
$changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? [];
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @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);
}
@@ -70,7 +74,7 @@ class MonitorCacheKeys implements CacheItemPoolInterface {
return $this->cache->hasItem($key);
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function clear(): bool {
/* only bother clearing the pool if it is not empty */
if ( ! empty($this->getKeys())) {
@@ -83,20 +87,14 @@ 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,61 +102,76 @@ 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);
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function save(CacheItemInterface $item): bool {
$this->update($item);
return $this->cache->save($item);
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function saveDeferred(CacheItemInterface $item): bool {
$this->update($item);
return $this->cache->saveDeferred($item);
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
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);
}
}
+20 -40
View File
@@ -7,40 +7,26 @@ 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)]
class PersistCache {
private MonitorCacheKeys $requestCache;
private MonitorCacheKeys $persistRequestCache;
final readonly class PersistCache {
private MonitorCacheKeys $sessionCache;
private MonitorCacheKeys $persistSessionCache;
private MonitorCacheKeys $sessionStorage;
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $requestCache,
CacheItemPoolInterface $persistRequestCache,
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $persistSessionCache
CacheItemPoolInterface $sessionStorage,
) {
$this->requestCache = new MonitorCacheKeys($requestCache);
$this->persistRequestCache = new MonitorCacheKeys($persistRequestCache);
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->persistSessionCache = new MonitorCacheKeys($persistSessionCache);
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function boot(): void {
/* the caches are considered warm as soon as they are not empty */
if (empty($this->requestCache->getKeys())) {
$items = $this->persistRequestCache->getItems($this->persistRequestCache->getKeys());
foreach ($items as $item) {
$this->requestCache->saveDeferred($item);
}
$this->requestCache->markClean();
$this->requestCache->commit();
}
if (empty($this->sessionCache->getKeys())) {
$items = $this->persistSessionCache->getItems($this->persistSessionCache->getKeys());
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
foreach ($items as $item) {
$this->sessionCache->saveDeferred($item);
}
@@ -49,27 +35,21 @@ class PersistCache {
}
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function persist(): void {
/* we only need to persist the caches if they contain changes */
if ($this->requestCache->isDirty()) {
$this->requestCache->markClean();
$items = $this->requestCache->getItems($this->requestCache->getKeys());
$this->persistRequestCache->clear();
foreach ($items as $item) {
$this->persistRequestCache->saveDeferred($item);
}
$this->persistRequestCache->commit();
}
if ($this->sessionCache->isDirty()) {
/* 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($this->sessionCache->getKeys());
$this->persistSessionCache->clear();
$items = $this->sessionCache->getItems(array_keys($changes));
foreach ($items as $item) {
$this->persistSessionCache->saveDeferred($item);
if (($changes[$item->getKey()] ?? null) === MonitorCacheKeys::REMOVED) {
$this->sessionStorage->deleteItem($item->getKey());
} else {
$this->sessionStorage->saveDeferred($item);
}
}
$this->persistSessionCache->commit();
$this->sessionStorage->commit();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Service;
use Exception;
use Psr\Cache\InvalidArgumentException;
/** backup-codes are caseinsensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
interface BackupCodeInterface {
/** 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 = 0): array;
/** @throws InvalidArgumentException */
public function expire(): void;
/** 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;
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\MonitorCacheKeys;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Trait\GetTotpTrait;
/** backup-codes are caseinsensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
final readonly class BackupCodeManager implements BackupCodeInterface {
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();
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Service;
interface DomainInterface {
/** 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;
/** 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;
/** 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;
/** 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;
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class DomainManager implements DomainInterface {
/* 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;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Service;
use App\Data\Payload;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface LoginInterface {
/** @throws InvalidArgumentException */
public function checkToken(Payload $payload, Request $request): ?Response;
}
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
final readonly class LoginManager implements LoginInterface {
use CookieNameTrait;
use GetTotpTrait;
use MakeNonceTrait;
use StringTrait;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $sessionCache,
private BackupCodeInterface $backupCodeManager,
private DomainInterface $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);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait CookieNameTrait {
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;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Trait;
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()
);
if ($otp instanceof TOTPInterface) {
return $otp;
}
throw new HttpException(500, 'Internal Server Exception');
}
}
+16
View File
@@ -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;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Trait;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait {
use HasLoggerTrait;
use StringTrait;
/* 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 {
/* convert raw binary into base64url */
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
)), '+/', '-_'), '=');
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) {
if ($retries < 1) {
$this->logger->error("aborting: multiple nonce collisions");
throw new HttpException(
Response::HTTP_INTERNAL_SERVER_ERROR,
'Internal Server Error'
);
}
/* managed to have a collision, try again */
return $this->makeNonce($retries - 1);
}
$nonceItem->set(true); /* valid */
$nonceItem->expiresAfter(static::NONCE_TTL);
$this->logger->debug("added nonce: $nonce");
$this->nonceCache->save($nonceItem);
return $nonce;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait StringTrait {
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
public function makeCacheKey(string $name): string {
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
}
}
+21 -112
View File
@@ -10,143 +10,52 @@ use OTPHP\TOTP;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
class Utilities {
/* top-level-domains which are known to have multiple parts
* stored backwards TLD[uk][co] means ".co.uk" */
private const 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 const PATH_REGEX = '/[^A-Za-z0-9_.\/-]+/';
private const NO_UP_REGEX = '/\.{2,}/';
private const KEY_REGEX = '/[^A-Za-z0-9_.]+/';
final readonly class Utilities {
public function __construct(
private readonly ClockInterface $clock,
private readonly CacheItemPoolInterface $appCache,
private ClockInterface $clock,
private CacheItemPoolInterface $appPool,
) {}
/**
* 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"
* @param string $host ip, localhost, or domain with zero or more subdomains
* @param bool $strict return null if host is ip or localhost, default false
* @return ?string returns same ip, hostname, or domain with all subdomains removed
*/
public function baseDomain(string $host, bool $strict = false): ?string {
/* if host is an ip address (or localhost), leave it as is */
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
return $strict ? null : $host;
}
$parts = explode('.', $host);
$keep = $this->baseLength($parts);
$parts = array_slice($parts, -$keep);
return implode('.', $parts);
}
/**
* Build new domain name based on base of the given host and given subdomain
* Handles edge cases like host being an ip or localhost
* @param ?string $subdomain subdomain to use, allowed to be null
* @param string $host domain (optionally with subdomains), ip, or localhost
* @return string returns a valid hostname, typically "subdomain.baseDomain"
*/
public function buildDomain(?string $subdomain, string $host): string {
$base = $this->baseDomain($host, true);
if ($base === null) {
return $host;
}
return ($subdomain === null || $subdomain === '')
? $base : "$subdomain.$base";
}
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;
}
public function makeCacheKey(string $name): string {
return preg_replace(self::KEY_REGEX, '_', $name);
}
public function cleanPath(string $path): string {
return preg_replace([self::PATH_REGEX, self::NO_UP_REGEX], ['', '.'], $path);
}
public function hash(ParameterBag $query): string {
/* remember this is *NOT* cryptographically secure */
$array = $query->all();
ksort($array);
return hash('xxh3', json_encode($array));
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
public function loadTotp(): string {
/* user forgot to set their TOTP_URI in the environment */
if ($this->appCache->hasItem('totp')) {
return $this->appCache->getItem('totp')->get();
if ($this->appPool->hasItem('totp')) {
$totp = $this->appPool->getItem('totp')->get();
} else {
$totp = $this->makeTotp();
}
return $this->makeTotp();
$this->showTotp($totp);
return $totp;
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
/** @throws InvalidArgumentException */
private function makeTotp(): string {
/* we have not stored a totp into the app cache yet */
$totpObj = TOTP::generate($this->clock);
$totpObj->setLabel('Preauth-TOTP');
$totp = $totpObj->getProvisioningUri();
$totpItem = $this->appCache->getItem('totp');
$totpItem = $this->appPool->getItem('totp');
$totpItem->set($totp);
/* 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 */
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
));
$this->appCache->save($totpItem);
$this->showTotp($totp);
$this->appPool->save($totpItem);
return $totp;
}
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
private function showTotp(string $totp): void {
/* only show this at most, every 5 minutes */
$suppress = $this->appCache->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
$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
RAW, FILE_APPEND
);
$suppress->expiresAfter(300);
$this->appCache->save($suppress);
}
RAW, FILE_APPEND
);
}
}
-9
View File
@@ -44,15 +44,6 @@
".editorconfig"
]
},
"symfony/maker-bundle": {
"version": "1.65",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/routing": {
"version": "7.4",
"recipe": {
-11
View File
@@ -1,11 +0,0 @@
<!-- Begin Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="https://{{ base_domain }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="https://{{ base_domain }}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="192x192" href="https://{{ base_domain }}/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="16x16" href="https://{{ base_domain }}/favicon-16x16.png">
<link rel="manifest" href="https://{{ base_domain }}/site.webmanifest">
<meta name="apple-mobile-web-app-title" content="{{ env.title }}">
<meta name="application-name" content="{{ env.title }}">
<meta name="msapplication-TileColor" content="{{ env.bg_color }}">
<meta name="theme-color" content="{{ env.bg_color }}">
<!-- End Icons -->
+84
View File
@@ -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>
+9 -4
View File
@@ -1,12 +1,17 @@
<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%; }
body { display: table-cell; vertical-align: middle; }
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
p { color: {{ env.error_color }}; text-align: center; }
form { display: flex; flex-wrap: wrap; justify-content: center; }
form div { width: 45%; }
div.right { text-align: right; }
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
form div { width: 45%; min-width: 300px; }
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
div.center { text-align: center; }
div.hidden { display: none; }
span { cursor: pointer; font-size: 0.75em; text-decoration: underline; }
button { background-color: #cccccc; }
input { background-color: #ffffff; max-width: 100%; }
button, input { border: 0.0625em solid #333333; border-radius: 0.25em; color: #333333; font-size: 0.9em; }
</style>
+3 -6
View File
@@ -3,13 +3,10 @@
<head>
<meta charset="utf-8">
<title>{{ env.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% if env.icons|default(false) %}
{{ include('_icons.html.twig') }}
{% endif %}
{{ include('_style.html.twig') }}
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{{- include('_style.html.twig') -}}
</head>
<body>
<body id="preauth-body">
{% block content %}{% endblock %}
</body>
</html>
+12 -9
View File
@@ -2,15 +2,18 @@
{% block content %}
<h1>{{ env.title }}</h1>
{% if has_error|default(false) %}
<p>{{ env.error_message }}</p>
{% endif %}
<form action="/" method="get">
<input type="hidden" name="{{ env.return_field }}" value="{{ return_value }}">
<div class="right"><label for="id">{{ env.id_name }}:</label></div>
<div><input type="text" name="{{ env.id_field }}" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
<div class="right"><label for="token">{{ env.token_name }}:</label></div>
<div><input type="text" name="{{ env.token_field }}" id="token" autocomplete="off" required="required"></div>
<p id="preauth-message">{{ message|default }}</p>
<form id="preauth-form" {% if post ?? false -%} method="post" {%- endif %}>
<input id="nonce" type="hidden" name="nonce" value="{{ nonce }}">
<div class="right"><label for="username">{{ env.id_name }}:</label></div>
<div><input type="text" name="username" id="username" {% if username ?? false %}value="{{ username }}"{% endif %}
autocomplete="username" required="required" autofocus="autofocus"></div>
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
<div><input type="text" name="totp" id="totp"
autocomplete="one-time-code" required="required"></div>
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
{% endblock %}