Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac817649ab | ||
|
|
1c4c289d81 | ||
|
|
b36aabb8a3 | ||
|
|
f486ab7481 | ||
|
|
a73d039e10 |
@@ -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
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
+1
-3
@@ -1,10 +1,8 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
/.env.local
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
|
||||
+40
-16
@@ -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.4-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 APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
ENV DEFAULT_URI='http://'
|
||||
|
||||
# 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
|
||||
|
||||
# install APCu
|
||||
RUN pecl install apcu && \
|
||||
docker-php-ext-enable apcu
|
||||
|
||||
# symfony required environment variables
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
ENV DEFAULT_URI='http://'
|
||||
|
||||
# load application into final image
|
||||
WORKDIR /app
|
||||
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
|
||||
|
||||
# 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
Binary file not shown.
+11
-15
@@ -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
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"spomky-labs/otphp": "^11.3",
|
||||
"symfony/cache": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
@@ -73,8 +72,5 @@
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/maker-bundle": "^1.65"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-392
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5b9dd032f7f62dd2b033ac94311a670e",
|
||||
"content-hash": "0347dfc31af034e9753433409f0f2a48",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1072,84 +1072,6 @@
|
||||
],
|
||||
"time": "2024-09-25T14:21:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dotenv",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dotenv.git",
|
||||
"reference": "1658a4d34df028f3d93bcdd8e81f04423925a364"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dotenv/zipball/1658a4d34df028f3d93bcdd8e81f04423925a364",
|
||||
"reference": "1658a4d34df028f3d93bcdd8e81f04423925a364",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<6.4",
|
||||
"symfony/process": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Dotenv\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Registers environment variables from a .env file",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"dotenv",
|
||||
"env",
|
||||
"environment"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dotenv/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-16T10:14:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/error-handler",
|
||||
"version": "v7.4.0",
|
||||
@@ -3588,319 +3510,7 @@
|
||||
"time": "2025-11-16T16:01:12+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "doctrine/inflector",
|
||||
"version": "2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/inflector.git",
|
||||
"reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b",
|
||||
"reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^12.0 || ^13.0",
|
||||
"phpstan/phpstan": "^1.12 || ^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.4 || ^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.6 || ^2.0",
|
||||
"phpunit/phpunit": "^8.5 || ^12.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Inflector\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Johannes Schmitt",
|
||||
"email": "schmittjoh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/inflector.html",
|
||||
"keywords": [
|
||||
"inflection",
|
||||
"inflector",
|
||||
"lowercase",
|
||||
"manipulation",
|
||||
"php",
|
||||
"plural",
|
||||
"singular",
|
||||
"strings",
|
||||
"uppercase",
|
||||
"words"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/inflector/issues",
|
||||
"source": "https://github.com/doctrine/inflector/tree/2.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-10T19:31:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"ircmaxell/php-yacc": "^0.0.7",
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"bin": [
|
||||
"bin/php-parse"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpParser\\": "lib/PhpParser"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nikita Popov"
|
||||
}
|
||||
],
|
||||
"description": "A PHP parser written in PHP",
|
||||
"keywords": [
|
||||
"parser",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
|
||||
},
|
||||
"time": "2025-12-06T11:56:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/maker-bundle",
|
||||
"version": "v1.65.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/maker-bundle.git",
|
||||
"reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/maker-bundle/zipball/eba30452d212769c9a5bcf0716959fd8ba1e54e3",
|
||||
"reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/inflector": "^2.0",
|
||||
"nikic/php-parser": "^5.0",
|
||||
"php": ">=8.1",
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/deprecation-contracts": "^2.2|^3",
|
||||
"symfony/filesystem": "^6.4|^7.0|^8.0",
|
||||
"symfony/finder": "^6.4|^7.0|^8.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/doctrine-bundle": "<2.10",
|
||||
"doctrine/orm": "<2.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/semver": "^3.0",
|
||||
"doctrine/doctrine-bundle": "^2.5.0|^3.0.0",
|
||||
"doctrine/orm": "^2.15|^3",
|
||||
"doctrine/persistence": "^3.1|^4.0",
|
||||
"symfony/http-client": "^6.4|^7.0|^8.0",
|
||||
"symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0",
|
||||
"symfony/security-core": "^6.4|^7.0|^8.0",
|
||||
"symfony/security-http": "^6.4|^7.0|^8.0",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0",
|
||||
"twig/twig": "^3.0|^4.x-dev"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bundle\\MakerBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.",
|
||||
"homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html",
|
||||
"keywords": [
|
||||
"code generator",
|
||||
"dev",
|
||||
"generator",
|
||||
"scaffold",
|
||||
"scaffolding"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/symfony/maker-bundle/issues",
|
||||
"source": "https://github.com/symfony/maker-bundle/tree/v1.65.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-02T07:14:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/process.git",
|
||||
"reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
|
||||
"reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Process\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Executes commands in sub-processes",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/process/tree/v7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-16T11:21:06+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
framework:
|
||||
cache:
|
||||
app: cache.adapter.filesystem
|
||||
|
||||
pools:
|
||||
sessionCache:
|
||||
noncePool:
|
||||
adapters:
|
||||
# TODO FIXME install APCu so we can use that...
|
||||
- cache.adapter.array
|
||||
#- cache.adapter.apcu
|
||||
requestCache:
|
||||
- cache.adapter.apcu
|
||||
sessionPool:
|
||||
adapters:
|
||||
- cache.adapter.array
|
||||
#- cache.adapter.apcu
|
||||
persistSessionCache:
|
||||
- cache.adapter.apcu
|
||||
requestPool:
|
||||
adapters:
|
||||
- cache.adapter.apcu
|
||||
persistSessionPool:
|
||||
adapters:
|
||||
- cache.adapter.filesystem
|
||||
persistRequestCache:
|
||||
persistRequestPool:
|
||||
adapters:
|
||||
- cache.adapter.filesystem
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ twig:
|
||||
globals:
|
||||
env:
|
||||
title: '%env(TITLE)%'
|
||||
icons: '%env(ICONS)%'
|
||||
bg_color: '%env(BG_COLOR)%'
|
||||
fg_color: '%env(FG_COLOR)%'
|
||||
error_color: '%env(ERROR_COLOR)%'
|
||||
|
||||
@@ -688,11 +688,6 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* }
|
||||
* @psalm-type MakerConfig = array{
|
||||
* root_namespace?: scalar|null, // Default: "App"
|
||||
* generate_final_classes?: bool, // Default: true
|
||||
* generate_final_entities?: bool, // Default: false
|
||||
* }
|
||||
* @psalm-type TwigConfig = array{
|
||||
* form_themes?: list<scalar|null>,
|
||||
* globals?: array<string, array{ // Default: []
|
||||
@@ -737,7 +732,6 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* maker?: MakerConfig,
|
||||
* twig?: TwigConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
|
||||
@@ -31,18 +31,13 @@ parameters:
|
||||
# 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 ---
|
||||
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'
|
||||
@@ -66,11 +61,13 @@ parameters:
|
||||
|
||||
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:
|
||||
|
||||
+8
-17
@@ -1,22 +1,13 @@
|
||||
# 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
|
||||
# snippet to put the preauth system in front any service easily
|
||||
(preauth) {
|
||||
# make sure caddy and preauth are on the same network
|
||||
reverse_proxy {args[0]} preauth {
|
||||
# leave body content for protected service
|
||||
method GET
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
# if auth is successful, send request to protected service
|
||||
@preauth_ok status 2xx
|
||||
handle_response @preauth_ok {
|
||||
copy_response_headers {
|
||||
include Set-Cookie Location
|
||||
}
|
||||
{block}
|
||||
}
|
||||
}
|
||||
@@ -25,16 +16,16 @@ preauth.example.com {
|
||||
# example of securing full subdomain
|
||||
# TODO replace domain and service name
|
||||
service.example.com {
|
||||
import preauth / {
|
||||
reverse_proxy service
|
||||
import preauth * {
|
||||
reverse_proxy service_container
|
||||
}
|
||||
}
|
||||
|
||||
# you can only lock down only select paths
|
||||
# or any other match criteria, if desired
|
||||
# https://protected.example.com/secure
|
||||
# https://protected.example.com/secure/
|
||||
protected.example.com {
|
||||
import preauth /secure {
|
||||
import preauth /secure/* {
|
||||
reverse_proxy protected-service:9000
|
||||
}
|
||||
reverse_proxy exposed-service:9000
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# TODO rename ".env.example" to just ".env", edit as needed
|
||||
# TODO rename "env.example" 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
|
||||
@@ -16,8 +16,10 @@ services:
|
||||
#
|
||||
#user: <uid>:<gid>
|
||||
volumes:
|
||||
- preauth:/app/var
|
||||
- preauth-config:/config
|
||||
- preauth-data:/data
|
||||
|
||||
volumes:
|
||||
preauth:
|
||||
preauth-config:
|
||||
preauth-data:
|
||||
|
||||
@@ -22,33 +22,18 @@
|
||||
|
||||
# --- 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
|
||||
|
||||
# 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 ---
|
||||
#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)
|
||||
@@ -16,12 +16,19 @@ It may be possible to use some other reverse proxy, but for now, I'm going to st
|
||||
|
||||
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.
|
||||
|
||||
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 redirected to the protected service.
|
||||
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.
|
||||
|
||||
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 those and add them to the containers environment, or it will generate new values every time it restarts.
|
||||
|
||||
**TODO**
|
||||
when user gives bad cookie, remove it
|
||||
|
||||
|
||||
|
||||
### History
|
||||
#### 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
@@ -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();
|
||||
}
|
||||
|
||||
+24
-15
@@ -3,10 +3,11 @@ 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;
|
||||
@@ -16,11 +17,13 @@ class ConfigBag {
|
||||
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,
|
||||
@@ -33,10 +36,11 @@ class ConfigBag {
|
||||
#[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,
|
||||
#[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;
|
||||
@@ -47,10 +51,11 @@ class ConfigBag {
|
||||
$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->errorMessage = $errorMessage;
|
||||
$this->teapotTitle = $teapotTitle;
|
||||
$this->tooManyTitle = $tooManyTitle;
|
||||
}
|
||||
|
||||
public function clock(): ClockInterface {
|
||||
@@ -89,19 +94,23 @@ class ConfigBag {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use App\Enum\Scope;
|
||||
|
||||
/* When scope is Ip but ip-access is disabled, scope is to be considered Cookie. */
|
||||
/* When using password but password is disabled, request will always fail. */
|
||||
final class Payload {
|
||||
public string $id; /* session name, identifying who is logging in */
|
||||
public ?string $token; /* totp, typically six digits */
|
||||
public ?string $password; /* static secret, alternative to token, if enabled */
|
||||
public string $nonce; /* random unique string, to block duplicate submissions */
|
||||
public bool $json; /* should we return json (for the login page) */
|
||||
public Scope $scope; /* type of access being requested */
|
||||
|
||||
public 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 create(object $data): ?Payload {
|
||||
/* if missing required fields id or nonce */
|
||||
if (strlen($data->id ?? '') < 1 ||
|
||||
strlen($data->nonce ?? '') < 1 ||
|
||||
/* if missing both token and password (we require one of them) */
|
||||
(strlen($data->token ?? '') < 1 &&
|
||||
strlen($data->password ?? '') < 1)
|
||||
) {
|
||||
/* returns null as the input is invalid */
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = new Payload();
|
||||
$payload->id = $data->id;
|
||||
$payload->nonce = $data->nonce;
|
||||
$payload->json = ($data->json ?? true);
|
||||
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
||||
|
||||
/* we accept either a token or a password, not both */
|
||||
if (strlen($data->token ?? '') > 0) {
|
||||
$payload->token = $data->token;
|
||||
$payload->password = null;
|
||||
} else {
|
||||
$payload->token = null;
|
||||
$payload->password = $data->password;
|
||||
}
|
||||
|
||||
return Payload::constrict($payload);
|
||||
}
|
||||
|
||||
public static function constrict(Payload $payload): Payload {
|
||||
/* When using password, scope will be considered None. */
|
||||
if ($payload->password) {
|
||||
$payload->scope = Scope::None;
|
||||
}
|
||||
|
||||
/* When scope is None, json will be considered false. */
|
||||
if ($payload->scope === Scope::None) {
|
||||
$payload->json = false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function toString(): string {
|
||||
return json_encode($this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum Scope: string {
|
||||
case Cookie = 'cookie';
|
||||
case Ip = 'ip';
|
||||
case None = 'none';
|
||||
}
|
||||
+4
-1
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AcceptListener {
|
||||
use CookieNameTrait;
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionPool,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
#[AsEventListener(priority: 99)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
/* check if they sent the preauth cookie */
|
||||
if ($event->getRequest()->cookies->has($this->cookieName())) {
|
||||
$cookie = $event->getRequest()->cookies->get($this->cookieName());
|
||||
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
||||
if ($this->sessionPool->hasItem($cookieKey)) {
|
||||
/* cookie sent corresponds to valid existing session */
|
||||
$id = $this->sessionPool->getItem($cookieKey)->get();
|
||||
$this->logger->debug("has valid cookie-session: $id");
|
||||
$event->setResponse(new Response("hi $id",
|
||||
headers: ['Content-Type' => 'text/plain']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AllowListener {
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionPool,
|
||||
private ConfigBag $config,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/** @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->sessionPool->hasItem($ipKey)) {
|
||||
/* ip address corresponds to valid existing session */
|
||||
$id = $this->sessionPool->getItem($ipKey)->get();
|
||||
$this->logger->debug("has valid ip-session: $id");
|
||||
$event->setResponse(new Response("hi $id",
|
||||
headers: ['Content-Type' => 'text/plain']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\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 MakeNonceTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $requestPool,
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
CacheItemPoolInterface $noncePool,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->logger = $logger;
|
||||
$this->noncePool = $noncePool;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
#[AsEventListener(priority: 55)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
if ($event->getRequest()) {
|
||||
/* by this point, we know that the request we have is:
|
||||
* not already authorized, nor already rate-limited,
|
||||
* nor submitting login credentials; so present the login page now */
|
||||
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
||||
$content = $this->twig->render('login.html.twig', [
|
||||
'nonce_value' => $this->makeNonce(),
|
||||
]);
|
||||
$event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED,
|
||||
['Content-Type' => 'text/html']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use OTPHP\Factory;
|
||||
use OTPHP\TOTPInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class LoginListener {
|
||||
use CookieNameTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
|
||||
private CacheItemPoolInterface $requestPool;
|
||||
private CacheItemPoolInterface $sessionPool;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
CacheItemPoolInterface $noncePool,
|
||||
CacheItemPoolInterface $requestPool,
|
||||
CacheItemPoolInterface $sessionPool,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->requestPool = new MonitorCacheKeys($requestPool);
|
||||
$this->sessionPool = new MonitorCacheKeys($sessionPool);
|
||||
$this->noncePool = $noncePool;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
||||
#[AsEventListener(priority: 66)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
if ($event->getRequest()->headers->has($this->headerName())) {
|
||||
$data = $event->getRequest()->headers->get($this->headerName());
|
||||
$payload = Payload::decode($data);
|
||||
$response = null;
|
||||
if ($payload) {
|
||||
/* if using token */
|
||||
if ($payload->token) {
|
||||
$response = $this->checkToken($payload, $event->getRequest());
|
||||
} else if ($this->config->staticSecret()) {
|
||||
$response = $this->checkPassword($payload);
|
||||
}
|
||||
}
|
||||
|
||||
/* token or password authentication was successful */
|
||||
if ($response) {
|
||||
$event->setResponse($response);
|
||||
return;
|
||||
}
|
||||
|
||||
$limitReached = $this->logFailure(
|
||||
$payload ? $payload->toString() : $data,
|
||||
$event->getRequest()
|
||||
);
|
||||
|
||||
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
|
||||
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true));
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function checkToken(Payload $payload, Request $request): ?Response {
|
||||
/* When scope is Ip but ip-access is disabled, scope will be considered Cookie. */
|
||||
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
|
||||
/* requested to grant ip access, but that is not enabled */
|
||||
$payload->scope = Scope::Cookie;
|
||||
}
|
||||
|
||||
if ($this->getTotp()->verify($payload->token, null, 10)) {
|
||||
/* token is correct */
|
||||
|
||||
/* if server nonce is found and is valid */
|
||||
$nonceItem = $this->noncePool->getItem($payload->nonce);
|
||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(60); /* keep for 1 minute */
|
||||
$this->noncePool->save($nonceItem);
|
||||
|
||||
/* token authentication successful, grant access and set response */
|
||||
$cleanId = $this->makeCacheKey($payload->id);
|
||||
|
||||
/* if they just want this one page, return ok, to grant them access */
|
||||
$response = new Response("hi $cleanId",
|
||||
headers: ['Content-Type' => 'text/plain']
|
||||
);
|
||||
|
||||
if ($payload->scope !== Scope::None) {
|
||||
/* grant access based on the requested scope */
|
||||
if ($payload->scope === Scope::Cookie) {
|
||||
$response->headers->setCookie($this->setCookie($cleanId));
|
||||
} else if ($payload->scope === Scope::Ip) {
|
||||
$this->setIp($cleanId, $request->getClientIp());
|
||||
}
|
||||
|
||||
if ($payload->json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode([
|
||||
'message' => 'Login successful',
|
||||
'nonce' => null,
|
||||
]);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = "hi $cleanId, please reload";
|
||||
}
|
||||
|
||||
$response->setContent($content)
|
||||
->setStatusCode(Response::HTTP_TEMPORARY_REDIRECT)
|
||||
->headers->set('Location',
|
||||
"{$request->getPathInfo()}{$request->getQueryString()}"
|
||||
);
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
}
|
||||
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function checkPassword(Payload $payload): ?Response {
|
||||
/* When using password but password is disabled, request will always fail. */
|
||||
|
||||
/* if password is correct */
|
||||
|
||||
if (hash_equals($this->config->staticSecret(), $payload->password)) {
|
||||
/* password is correct */
|
||||
|
||||
/* nonce *may* be client provided, but must still be unique */
|
||||
|
||||
/* if server/client nonce is acceptable (valid server or unused client) */
|
||||
$nonceItem = $this->noncePool->getItem($payload->nonce);
|
||||
if (($nonceItem->isHit() && $nonceItem->get()) || ! $nonceItem->isHit()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(60); /* keep for 1 minute */
|
||||
$this->noncePool->save($nonceItem);
|
||||
|
||||
/* password authentication successful, grant access and set response */
|
||||
$cleanId = $this->makeCacheKey($payload->id);
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
return new Response("hi $cleanId",
|
||||
headers: ['Content-Type' => 'text/plain']
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setCookie(string $id): Cookie {
|
||||
/* successful auth with token, store session and set the cookie */
|
||||
$ulid = new Ulid();
|
||||
$sessionCookie = $this->sessionPool->getItem(
|
||||
$this->makeCacheKey("cookie_$ulid")
|
||||
);
|
||||
if ($sessionCookie->isHit()) {
|
||||
/* it is supposed to be impossible to have collisions */
|
||||
$this->logger->error("aborting: ULID collision");
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
}
|
||||
$sessionCookie->set($id);
|
||||
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
||||
$this->sessionPool->save($sessionCookie);
|
||||
|
||||
return Cookie::create(
|
||||
name: $this->cookieName(),
|
||||
value: $ulid->toString(),
|
||||
expire: time() + $this->config->cookieTtl(),
|
||||
secure: true,
|
||||
sameSite: Cookie::SAMESITE_STRICT
|
||||
);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setIp(string $id, string $ip): void {
|
||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||
|
||||
$sessionIp = $this->sessionPool->getItem($ipKey);
|
||||
$sessionIp->set($id);
|
||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
||||
$this->sessionPool->save($sessionIp);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function logFailure(string $data, Request $request): bool {
|
||||
// TODO use rate-limiting symfony system (also update RejectListener)
|
||||
$timeframe = (int)floor(time() / $this->getTotp()->getPeriod());
|
||||
/* hash the data and timeframe, so we do not count duplicates in the same timeframe
|
||||
* hitting refresh a few times should not lock you out */
|
||||
$ipKey = $this->makeCacheKey("ip_{$request->getClientIp()}");
|
||||
$failuresItem = $this->requestPool->getItem($ipKey);
|
||||
$failures = $failuresItem->get() ?? [];
|
||||
$failures[hash('xxh3', "$timeframe-$data")] = true;
|
||||
$limitReached = count($failures) >= $this->config->limit();
|
||||
$failuresItem->set($failures);
|
||||
$failuresItem->expiresAfter($limitReached
|
||||
? $this->config->limitTtl() : $this->config->limitTimeout()
|
||||
);
|
||||
$this->requestPool->save($failuresItem);
|
||||
return $limitReached;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
private function makeFailedResponse(bool $limited, bool $json): 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();
|
||||
}
|
||||
|
||||
if ($json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode([
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
]);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = $this->twig->render('login.html.twig', [
|
||||
'error_message' => $message,
|
||||
'nonce_value' => $this->makeNonce(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new Response($content, $status, ["Content-Type" => $contentType]);
|
||||
}
|
||||
|
||||
private 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class RejectListener {
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $requestPool,
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/** @throws SyntaxError|InvalidArgumentException|RuntimeError|LoaderError */
|
||||
#[AsEventListener(priority: 77)]
|
||||
public function onKernelRequest(RequestEvent $event): void {
|
||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||
|
||||
/* check if they have made too many failed login attempts */
|
||||
$failuresItem = $this->requestPool->getItem($ipKey);
|
||||
if ($failuresItem->isHit()) {
|
||||
$failures = $failuresItem->get() ?? [];
|
||||
if (count($failures) >= $this->config->limit()) {
|
||||
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
|
||||
$html = $this->twig->render('error.html.twig');
|
||||
$event->setResponse(new Response($html, ($this->config->teapot()
|
||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
||||
['Content-Type' => 'text/html']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -10,13 +10,13 @@ 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 {
|
||||
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
private const KEY_LIST = '__key_list';
|
||||
private const IS_DIRTY = '__is_dirty';
|
||||
|
||||
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]);
|
||||
@@ -28,7 +28,7 @@ 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);
|
||||
@@ -39,19 +39,19 @@ class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
$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 */
|
||||
/** @throws InvalidArgumentException */
|
||||
public function isDirty(): bool {
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
return $isDirty->get() ?? false;
|
||||
}
|
||||
|
||||
/** @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);
|
||||
@@ -70,7 +70,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())) {
|
||||
@@ -128,19 +128,19 @@ class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
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 */
|
||||
/** @throws InvalidArgumentException */
|
||||
private function update(CacheItemInterface $item) {
|
||||
if ($item->getKey() === self::KEY_LIST || $item->getKey() === self::IS_DIRTY) {
|
||||
throw new OutOfBoundsException(
|
||||
|
||||
+38
-38
@@ -8,68 +8,68 @@ use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
|
||||
#[Autoconfigure(public: true)]
|
||||
class PersistCache {
|
||||
private MonitorCacheKeys $requestCache;
|
||||
private MonitorCacheKeys $persistRequestCache;
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
private MonitorCacheKeys $persistSessionCache;
|
||||
final readonly class PersistCache {
|
||||
private MonitorCacheKeys $requestPool;
|
||||
private MonitorCacheKeys $persistRequestPool;
|
||||
private MonitorCacheKeys $sessionPool;
|
||||
private MonitorCacheKeys $persistSessionPool;
|
||||
|
||||
/** @throws InvalidArgumentException we sanitize cache keys, to prevent this */
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $requestCache,
|
||||
CacheItemPoolInterface $persistRequestCache,
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
CacheItemPoolInterface $persistSessionCache
|
||||
CacheItemPoolInterface $requestPool,
|
||||
CacheItemPoolInterface $persistRequestPool,
|
||||
CacheItemPoolInterface $sessionPool,
|
||||
CacheItemPoolInterface $persistSessionPool
|
||||
) {
|
||||
$this->requestCache = new MonitorCacheKeys($requestCache);
|
||||
$this->persistRequestCache = new MonitorCacheKeys($persistRequestCache);
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
$this->persistSessionCache = new MonitorCacheKeys($persistSessionCache);
|
||||
$this->requestPool = new MonitorCacheKeys($requestPool);
|
||||
$this->persistRequestPool = new MonitorCacheKeys($persistRequestPool);
|
||||
$this->sessionPool = new MonitorCacheKeys($sessionPool);
|
||||
$this->persistSessionPool = new MonitorCacheKeys($persistSessionPool);
|
||||
}
|
||||
|
||||
/** @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());
|
||||
if (empty($this->requestPool->getKeys())) {
|
||||
$items = $this->persistRequestPool->getItems($this->persistRequestPool->getKeys());
|
||||
foreach ($items as $item) {
|
||||
$this->requestCache->saveDeferred($item);
|
||||
$this->requestPool->saveDeferred($item);
|
||||
}
|
||||
$this->requestCache->markClean();
|
||||
$this->requestCache->commit();
|
||||
$this->requestPool->markClean();
|
||||
$this->requestPool->commit();
|
||||
}
|
||||
|
||||
if (empty($this->sessionCache->getKeys())) {
|
||||
$items = $this->persistSessionCache->getItems($this->persistSessionCache->getKeys());
|
||||
if (empty($this->sessionPool->getKeys())) {
|
||||
$items = $this->persistSessionPool->getItems($this->persistSessionPool->getKeys());
|
||||
foreach ($items as $item) {
|
||||
$this->sessionCache->saveDeferred($item);
|
||||
$this->sessionPool->saveDeferred($item);
|
||||
}
|
||||
$this->sessionCache->markClean();
|
||||
$this->sessionCache->commit();
|
||||
$this->sessionPool->markClean();
|
||||
$this->sessionPool->commit();
|
||||
}
|
||||
}
|
||||
|
||||
/** @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();
|
||||
if ($this->requestPool->isDirty()) {
|
||||
$this->requestPool->markClean();
|
||||
$items = $this->requestPool->getItems($this->requestPool->getKeys());
|
||||
$this->persistRequestPool->clear();
|
||||
foreach ($items as $item) {
|
||||
$this->persistRequestCache->saveDeferred($item);
|
||||
$this->persistRequestPool->saveDeferred($item);
|
||||
}
|
||||
$this->persistRequestCache->commit();
|
||||
$this->persistRequestPool->commit();
|
||||
}
|
||||
|
||||
if ($this->sessionCache->isDirty()) {
|
||||
$this->sessionCache->markClean();
|
||||
$items = $this->sessionCache->getItems($this->sessionCache->getKeys());
|
||||
$this->persistSessionCache->clear();
|
||||
if ($this->sessionPool->isDirty()) {
|
||||
$this->sessionPool->markClean();
|
||||
$items = $this->sessionPool->getItems($this->sessionPool->getKeys());
|
||||
$this->persistSessionPool->clear();
|
||||
foreach ($items as $item) {
|
||||
$this->persistSessionCache->saveDeferred($item);
|
||||
$this->persistSessionPool->saveDeferred($item);
|
||||
}
|
||||
$this->persistSessionCache->commit();
|
||||
$this->persistSessionPool->commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
trait CookieNameTrait {
|
||||
private const COOKIE_NAME = '__Host-Http-Preauth';
|
||||
private const HEADER_NAME = 'X-Preauth';
|
||||
|
||||
final protected function cookieName(): string {
|
||||
return static::COOKIE_NAME;
|
||||
}
|
||||
|
||||
final protected function headerName(): string {
|
||||
return static::HEADER_NAME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
use Exception;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
trait MakeNonceTrait {
|
||||
/* 15 bytes neatly fits in base64 */
|
||||
private const NONCE_LENGTH = 15;
|
||||
private const NONCE_TTL = 60;
|
||||
|
||||
protected readonly CacheItemPoolInterface $noncePool;
|
||||
protected readonly LoggerInterface $logger;
|
||||
|
||||
/** @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->noncePool->getItem($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->noncePool->save($nonceItem);
|
||||
return $nonce;
|
||||
}
|
||||
}
|
||||
@@ -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 KEY_REGEX = '/[^A-Za-z0-9_.]+/';
|
||||
|
||||
public function makeCacheKey(string $name): string {
|
||||
return preg_replace(static::KEY_REGEX, '_', $name);
|
||||
}
|
||||
}
|
||||
+27
-112
@@ -10,143 +10,58 @@ 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
|
||||
// /* only show this at most, every 5 minutes */
|
||||
// $suppress = $this->appPool->getItem('suppress');
|
||||
// if ( ! $suppress->isHit()) {
|
||||
$writer = new Writer(new PlainTextRenderer());
|
||||
file_put_contents(
|
||||
'php://stderr', <<<RAW
|
||||
{$writer->writeString($totp)}
|
||||
$totp
|
||||
loading totp, because the env is not set, please copy above into TOTP_URI
|
||||
|
||||
RAW, FILE_APPEND
|
||||
);
|
||||
$suppress->expiresAfter(300);
|
||||
$this->appCache->save($suppress);
|
||||
}
|
||||
RAW, FILE_APPEND
|
||||
);
|
||||
// $suppress->expiresAfter(300);
|
||||
// $this->appPool->save($suppress);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
0
|
||||
__key_list
|
||||
a:0:{}
|
||||
@@ -0,0 +1,3 @@
|
||||
0
|
||||
__is_dirty
|
||||
b:0;
|
||||
@@ -0,0 +1,3 @@
|
||||
0
|
||||
__key_list
|
||||
a:0:{}
|
||||
@@ -0,0 +1,3 @@
|
||||
0
|
||||
__is_dirty
|
||||
b:0;
|
||||
@@ -0,0 +1,3 @@
|
||||
1766103213
|
||||
suppress
|
||||
N;
|
||||
@@ -0,0 +1,3 @@
|
||||
32503594112
|
||||
totp
|
||||
s:138:"otpauth://totp/Preauth-TOTP?secret=5RL5FOJGV4XRKGT74ZVN4725OAM244SU7JYXYX4SHQDTJI4P3YKBYAFUVBBOCLI5XSOLERNB6IQQ54SIGY6QHJ26JM4OP3ZJVBUUBIY";
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 -->
|
||||
@@ -4,9 +4,6 @@
|
||||
<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') }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,15 +2,67 @@
|
||||
|
||||
{% 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">{{ error_message|default }}</p>
|
||||
<form id="preauth-form">
|
||||
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce_value }}">
|
||||
<div class="right"><label for="preauth-id">{{ env.id_name }}:</label></div>
|
||||
<div><input type="text" name="preauth_id" id="preauth-id"
|
||||
autocomplete="username" required="required" autofocus="autofocus"></div>
|
||||
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div>
|
||||
<div><input type="text" name="preauth_token" id="preauth-token"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
|
||||
</form>
|
||||
<script>
|
||||
const form = document.getElementById('preauth-form');
|
||||
const message = document.getElementById('preauth-message');
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
/* make bas64url string containing our payload json object */
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.preauth_id.value,
|
||||
token: form.preauth_token.value,
|
||||
nonce: form.preauth_nonce.value,
|
||||
json: true
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
/* send our request to the server */
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: { 'X-Preauth': data },
|
||||
}).then((response) => {
|
||||
if (response.headers.has('Location')) {
|
||||
/* follow redirect (not needed in most browsers) */
|
||||
window.location.href = response.headers.get('Location');
|
||||
} else if (response.headers.get('Content-Type') === 'application/json') {
|
||||
/* got json, update the page */
|
||||
response.json().then((content) => {
|
||||
if (Object.hasOwn(content, 'message')) {
|
||||
message.innerText = content.message;
|
||||
}
|
||||
if (Object.hasOwn(content, 'nonce')) {
|
||||
form.preauth_nonce.value = content.nonce;
|
||||
form.preauth_token.value = '';
|
||||
form.preauth_token.focus();
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.log('failed to parse json from response');
|
||||
console.log(error);
|
||||
});
|
||||
} else { /* non-json, non-redirect response */
|
||||
/* overwrite the page */
|
||||
response.text().then((text) => {
|
||||
document.open();
|
||||
document.write(text);
|
||||
document.close();
|
||||
}).catch((error) => {
|
||||
console.log('failed to get text from response');
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user