9 Commits
Author SHA1 Message Date
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
36 changed files with 1048 additions and 1053 deletions
-2
View File
@@ -12,7 +12,6 @@ RUN apt-get update && \
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
ENV DEFAULT_URI='http://'
# load application into build image
RUN mkdir -p /data/preauth
@@ -42,7 +41,6 @@ RUN pecl install apcu && \
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
ENV DEFAULT_URI='http://'
# load application into final image
WORKDIR /app
+4 -8
View File
@@ -7,14 +7,15 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"bacon/bacon-qr-code": "^3.0",
"bacon/bacon-qr-code": "^3.0.3",
"runtime/frankenphp-symfony": "^0.2.0",
"spomky-labs/otphp": "^11.3",
"spomky-labs/otphp": "^11.4.2",
"symfony/cache": "7.4.*",
"symfony/console": "7.4.*",
"symfony/flex": "^2",
"symfony/flex": "^2.10",
"symfony/framework-bundle": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
@@ -34,11 +35,6 @@
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
Generated
+290 -156
View File
@@ -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": "0347dfc31af034e9753433409f0f2a48",
"content-hash": "1399aeacc08847b58b9a2df2b729f243",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -484,38 +484,26 @@
},
{
"name": "spomky-labs/otphp",
"version": "11.3.0",
"version": "11.4.2",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/otphp.git",
"reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33"
"reference": "2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33",
"reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33",
"url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad",
"reference": "2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"paragonie/constant_time_encoding": "^2.0 || ^3.0",
"php": ">=8.1",
"psr/clock": "^1.0",
"symfony/deprecation-contracts": "^3.2"
},
"require-dev": {
"ekino/phpstan-banned-code": "^1.0",
"infection/infection": "^0.26|^0.27|^0.28|^0.29",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": "^9.5.26|^10.0|^11.0",
"qossmic/deptrac-shim": "^1.0",
"rector/rector": "^1.0",
"symfony/phpunit-bridge": "^6.1|^7.0",
"symplify/easy-coding-standard": "^12.0"
"symfony/error-handler": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -550,7 +538,7 @@
],
"support": {
"issues": "https://github.com/Spomky-Labs/otphp/issues",
"source": "https://github.com/Spomky-Labs/otphp/tree/11.3.0"
"source": "https://github.com/Spomky-Labs/otphp/tree/11.4.2"
},
"funding": [
{
@@ -562,20 +550,20 @@
"type": "patreon"
}
],
"time": "2024-06-12T11:22:32+00:00"
"time": "2026-01-23T10:53:01+00:00"
},
{
"name": "symfony/cache",
"version": "v7.4.1",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/cache.git",
"reference": "21e0755783bbbab58f2bb6a7a57896d21d27a366"
"reference": "665522ec357540e66c294c08583b40ee576574f0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/21e0755783bbbab58f2bb6a7a57896d21d27a366",
"reference": "21e0755783bbbab58f2bb6a7a57896d21d27a366",
"url": "https://api.github.com/repos/symfony/cache/zipball/665522ec357540e66c294c08583b40ee576574f0",
"reference": "665522ec357540e66c294c08583b40ee576574f0",
"shasum": ""
},
"require": {
@@ -646,7 +634,7 @@
"psr6"
],
"support": {
"source": "https://github.com/symfony/cache/tree/v7.4.1"
"source": "https://github.com/symfony/cache/tree/v7.4.7"
},
"funding": [
{
@@ -666,7 +654,7 @@
"type": "tidelift"
}
],
"time": "2025-12-04T18:11:45+00:00"
"time": "2026-03-06T08:14:57+00:00"
},
{
"name": "symfony/cache-contracts",
@@ -746,16 +734,16 @@
},
{
"name": "symfony/config",
"version": "v7.4.1",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/config.git",
"reference": "2c323304c354a43a48b61c5fa760fc4ed60ce495"
"reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/config/zipball/2c323304c354a43a48b61c5fa760fc4ed60ce495",
"reference": "2c323304c354a43a48b61c5fa760fc4ed60ce495",
"url": "https://api.github.com/repos/symfony/config/zipball/6c17162555bfb58957a55bb0e43e00035b6ae3d5",
"reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5",
"shasum": ""
},
"require": {
@@ -801,7 +789,7 @@
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/config/tree/v7.4.1"
"source": "https://github.com/symfony/config/tree/v7.4.7"
},
"funding": [
{
@@ -821,20 +809,20 @@
"type": "tidelift"
}
],
"time": "2025-12-05T07:52:08+00:00"
"time": "2026-03-06T10:41:14+00:00"
},
{
"name": "symfony/console",
"version": "v7.4.1",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e"
"reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e",
"reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e",
"url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d",
"reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d",
"shasum": ""
},
"require": {
@@ -899,7 +887,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.1"
"source": "https://github.com/symfony/console/tree/v7.4.7"
},
"funding": [
{
@@ -919,20 +907,20 @@
"type": "tidelift"
}
],
"time": "2025-12-05T15:23:39+00:00"
"time": "2026-03-06T14:06:20+00:00"
},
{
"name": "symfony/dependency-injection",
"version": "v7.4.2",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/dependency-injection.git",
"reference": "baf614f7c15b30ba6762d4b1ddabdf83dbf0d29b"
"reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/baf614f7c15b30ba6762d4b1ddabdf83dbf0d29b",
"reference": "baf614f7c15b30ba6762d4b1ddabdf83dbf0d29b",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db",
"reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db",
"shasum": ""
},
"require": {
@@ -983,7 +971,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.2"
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.7"
},
"funding": [
{
@@ -1003,7 +991,7 @@
"type": "tidelift"
}
],
"time": "2025-12-08T06:57:04+00:00"
"time": "2026-03-03T07:48:48+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -1074,16 +1062,16 @@
},
{
"name": "symfony/error-handler",
"version": "v7.4.0",
"version": "v7.4.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "48be2b0653594eea32dcef130cca1c811dcf25c2"
"reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2",
"reference": "48be2b0653594eea32dcef130cca1c811dcf25c2",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8",
"reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8",
"shasum": ""
},
"require": {
@@ -1132,7 +1120,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v7.4.0"
"source": "https://github.com/symfony/error-handler/tree/v7.4.4"
},
"funding": [
{
@@ -1152,20 +1140,20 @@
"type": "tidelift"
}
],
"time": "2025-11-05T14:29:59+00:00"
"time": "2026-01-20T16:42:42+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v7.4.0",
"version": "v7.4.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d"
"reference": "dc2c0eba1af673e736bb851d747d266108aea746"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d",
"reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746",
"reference": "dc2c0eba1af673e736bb851d747d266108aea746",
"shasum": ""
},
"require": {
@@ -1217,7 +1205,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0"
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4"
},
"funding": [
{
@@ -1237,7 +1225,7 @@
"type": "tidelift"
}
],
"time": "2025-10-28T09:38:46+00:00"
"time": "2026-01-05T11:45:34+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -1317,16 +1305,16 @@
},
{
"name": "symfony/filesystem",
"version": "v7.4.0",
"version": "v7.4.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "d551b38811096d0be9c4691d406991b47c0c630a"
"reference": "3ebc794fa5315e59fd122561623c2e2e4280538e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a",
"reference": "d551b38811096d0be9c4691d406991b47c0c630a",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e",
"reference": "3ebc794fa5315e59fd122561623c2e2e4280538e",
"shasum": ""
},
"require": {
@@ -1363,7 +1351,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v7.4.0"
"source": "https://github.com/symfony/filesystem/tree/v7.4.6"
},
"funding": [
{
@@ -1383,20 +1371,20 @@
"type": "tidelift"
}
],
"time": "2025-11-27T13:27:24+00:00"
"time": "2026-02-25T16:50:00+00:00"
},
{
"name": "symfony/finder",
"version": "v7.4.0",
"version": "v7.4.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "340b9ed7320570f319028a2cbec46d40535e94bd"
"reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd",
"reference": "340b9ed7320570f319028a2cbec46d40535e94bd",
"url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf",
"reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf",
"shasum": ""
},
"require": {
@@ -1431,7 +1419,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.0"
"source": "https://github.com/symfony/finder/tree/v7.4.6"
},
"funding": [
{
@@ -1451,7 +1439,7 @@
"type": "tidelift"
}
],
"time": "2025-11-05T05:42:40+00:00"
"time": "2026-01-29T09:40:50+00:00"
},
{
"name": "symfony/flex",
@@ -1528,16 +1516,16 @@
},
{
"name": "symfony/framework-bundle",
"version": "v7.4.1",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/framework-bundle.git",
"reference": "2fa3b3ad6ed75ce0cc8cad8a5027b4f25b990bc3"
"reference": "c94bc78c85d76af67918404a95d44940f66a7c2f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/2fa3b3ad6ed75ce0cc8cad8a5027b4f25b990bc3",
"reference": "2fa3b3ad6ed75ce0cc8cad8a5027b4f25b990bc3",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c94bc78c85d76af67918404a95d44940f66a7c2f",
"reference": "c94bc78c85d76af67918404a95d44940f66a7c2f",
"shasum": ""
},
"require": {
@@ -1545,8 +1533,8 @@
"ext-xml": "*",
"php": ">=8.2",
"symfony/cache": "^6.4.12|^7.0|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/config": "^7.4.4|^8.0.4",
"symfony/dependency-injection": "^7.4.4|^8.0.4",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/error-handler": "^7.3|^8.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
@@ -1560,8 +1548,8 @@
},
"conflict": {
"doctrine/persistence": "<1.3",
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1",
"symfony/asset": "<6.4",
"symfony/asset-mapper": "<6.4",
"symfony/clock": "<6.4",
@@ -1593,7 +1581,7 @@
"require-dev": {
"doctrine/persistence": "^1.3|^2|^3",
"dragonmantank/cron-expression": "^3.1",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"seld/jsonlint": "^1.10",
"symfony/asset": "^6.4|^7.0|^8.0",
"symfony/asset-mapper": "^6.4|^7.0|^8.0",
@@ -1662,7 +1650,7 @@
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.1"
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.7"
},
"funding": [
{
@@ -1682,20 +1670,20 @@
"type": "tidelift"
}
],
"time": "2025-12-05T14:04:53+00:00"
"time": "2026-03-06T15:39:55+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v7.4.1",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "bd1af1e425811d6f077db240c3a588bdb405cd27"
"reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27",
"reference": "bd1af1e425811d6f077db240c3a588bdb405cd27",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
"reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
"shasum": ""
},
"require": {
@@ -1744,7 +1732,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.1"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.7"
},
"funding": [
{
@@ -1764,20 +1752,20 @@
"type": "tidelift"
}
],
"time": "2025-12-07T11:13:10+00:00"
"time": "2026-03-06T13:15:18+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.4.2",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f"
"reference": "3b3fcf386c809be990c922e10e4c620d6367cab1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f",
"reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1",
"reference": "3b3fcf386c809be990c922e10e4c620d6367cab1",
"shasum": ""
},
"require": {
@@ -1819,7 +1807,7 @@
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/css-selector": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0",
"symfony/dom-crawler": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
@@ -1863,7 +1851,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.2"
"source": "https://github.com/symfony/http-kernel/tree/v7.4.7"
},
"funding": [
{
@@ -1883,20 +1871,20 @@
"type": "tidelift"
}
],
"time": "2025-12-08T07:43:37+00:00"
"time": "2026-03-06T16:33:18+00:00"
},
{
"name": "symfony/mime",
"version": "v7.4.0",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a"
"reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a",
"reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a",
"url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
"reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
"shasum": ""
},
"require": {
@@ -1907,15 +1895,15 @@
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1",
"symfony/mailer": "<6.4",
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/property-access": "^6.4|^7.0|^8.0",
@@ -1952,7 +1940,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.4.0"
"source": "https://github.com/symfony/mime/tree/v7.4.7"
},
"funding": [
{
@@ -1972,7 +1960,78 @@
"type": "tidelift"
}
],
"time": "2025-11-16T10:14:42+00:00"
"time": "2026-03-05T15:24:09+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v7.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "b38026df55197f9e39a44f3215788edf83187b80"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80",
"reference": "b38026df55197f9e39a44f3215788edf83187b80",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"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": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/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-12T15:39:26+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
@@ -2477,17 +2536,91 @@
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/routing",
"version": "v7.4.0",
"name": "symfony/rate-limiter",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "4720254cb2644a0b876233d258a32bf017330db7"
"url": "https://github.com/symfony/rate-limiter.git",
"reference": "c2ff01c8d5ed54f0721f046fde14a94f2df09666"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7",
"reference": "4720254cb2644a0b876233d258a32bf017330db7",
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/c2ff01c8d5ed54f0721f046fde14a94f2df09666",
"reference": "c2ff01c8d5ed54f0721f046fde14a94f2df09666",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/options-resolver": "^7.3|^8.0"
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
"symfony/lock": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\RateLimiter\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Wouter de Jong",
"email": "wouter@wouterj.nl"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a Token Bucket implementation to rate limit input and output in your application",
"homepage": "https://symfony.com",
"keywords": [
"limiter",
"rate-limiter"
],
"support": {
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.7"
},
"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": "2026-03-04T13:54:41+00:00"
},
{
"name": "symfony/routing",
"version": "v7.4.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "238d749c56b804b31a9bf3e26519d93b65a60938"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938",
"reference": "238d749c56b804b31a9bf3e26519d93b65a60938",
"shasum": ""
},
"require": {
@@ -2539,7 +2672,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.4.0"
"source": "https://github.com/symfony/routing/tree/v7.4.6"
},
"funding": [
{
@@ -2559,7 +2692,7 @@
"type": "tidelift"
}
],
"time": "2025-11-27T13:27:24+00:00"
"time": "2026-02-25T16:50:00+00:00"
},
{
"name": "symfony/runtime",
@@ -2733,16 +2866,16 @@
},
{
"name": "symfony/string",
"version": "v7.4.0",
"version": "v7.4.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003"
"reference": "9f209231affa85aa930a5e46e6eb03381424b30b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003",
"reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003",
"url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b",
"reference": "9f209231affa85aa930a5e46e6eb03381424b30b",
"shasum": ""
},
"require": {
@@ -2800,7 +2933,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v7.4.0"
"source": "https://github.com/symfony/string/tree/v7.4.6"
},
"funding": [
{
@@ -2820,7 +2953,7 @@
"type": "tidelift"
}
],
"time": "2025-11-27T13:27:24+00:00"
"time": "2026-02-09T09:33:46+00:00"
},
{
"name": "symfony/translation-contracts",
@@ -2906,16 +3039,16 @@
},
{
"name": "symfony/twig-bridge",
"version": "v7.4.1",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bridge.git",
"reference": "9103559ef3e9f06708d8bff6810f6335b8f1eee8"
"reference": "c67219ca6b79a57b64e36bbb2cd8ba741286587e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/9103559ef3e9f06708d8bff6810f6335b8f1eee8",
"reference": "9103559ef3e9f06708d8bff6810f6335b8f1eee8",
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/c67219ca6b79a57b64e36bbb2cd8ba741286587e",
"reference": "c67219ca6b79a57b64e36bbb2cd8ba741286587e",
"shasum": ""
},
"require": {
@@ -2925,10 +3058,10 @@
"twig/twig": "^3.21"
},
"conflict": {
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1",
"symfony/console": "<6.4",
"symfony/form": "<6.4",
"symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4",
"symfony/http-foundation": "<6.4",
"symfony/http-kernel": "<6.4",
"symfony/mime": "<6.4",
@@ -2939,7 +3072,7 @@
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/asset": "^6.4|^7.0|^8.0",
"symfony/asset-mapper": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
@@ -2947,7 +3080,7 @@
"symfony/emoji": "^7.1|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/form": "^6.4.30|~7.3.8|^7.4.1|^8.0.1",
"symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4",
"symfony/html-sanitizer": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.3|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
@@ -2997,7 +3130,7 @@
"description": "Provides integration for Twig with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/twig-bridge/tree/v7.4.1"
"source": "https://github.com/symfony/twig-bridge/tree/v7.4.7"
},
"funding": [
{
@@ -3017,20 +3150,20 @@
"type": "tidelift"
}
],
"time": "2025-12-05T14:04:53+00:00"
"time": "2026-03-04T15:37:05+00:00"
},
{
"name": "symfony/twig-bundle",
"version": "v7.4.0",
"version": "v7.4.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bundle.git",
"reference": "f83f530d00d1bbc6f7fafeb433077887c83326ef"
"reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bundle/zipball/f83f530d00d1bbc6f7fafeb433077887c83326ef",
"reference": "f83f530d00d1bbc6f7fafeb433077887c83326ef",
"url": "https://api.github.com/repos/symfony/twig-bundle/zipball/e8829e02ff96a391ed0703bac9e7ff0537480b6b",
"reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b",
"shasum": ""
},
"require": {
@@ -3038,6 +3171,7 @@
"php": ">=8.2",
"symfony/config": "^7.4|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4.13|^7.1.6|^8.0",
"symfony/twig-bridge": "^7.3|^8.0",
@@ -3086,7 +3220,7 @@
"description": "Provides a tight integration of Twig into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/twig-bundle/tree/v7.4.0"
"source": "https://github.com/symfony/twig-bundle/tree/v7.4.4"
},
"funding": [
{
@@ -3106,20 +3240,20 @@
"type": "tidelift"
}
],
"time": "2025-10-02T07:41:02+00:00"
"time": "2026-01-06T12:34:24+00:00"
},
{
"name": "symfony/uid",
"version": "v7.4.0",
"version": "v7.4.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/uid.git",
"reference": "2498e9f81b7baa206f44de583f2f48350b90142c"
"reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c",
"reference": "2498e9f81b7baa206f44de583f2f48350b90142c",
"url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36",
"reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36",
"shasum": ""
},
"require": {
@@ -3164,7 +3298,7 @@
"uuid"
],
"support": {
"source": "https://github.com/symfony/uid/tree/v7.4.0"
"source": "https://github.com/symfony/uid/tree/v7.4.4"
},
"funding": [
{
@@ -3184,20 +3318,20 @@
"type": "tidelift"
}
],
"time": "2025-09-25T11:02:55+00:00"
"time": "2026-01-03T23:30:35+00:00"
},
{
"name": "symfony/var-dumper",
"version": "v7.4.0",
"version": "v7.4.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "41fd6c4ae28c38b294b42af6db61446594a0dece"
"reference": "045321c440ac18347b136c63d2e9bf28a2dc0291"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece",
"reference": "41fd6c4ae28c38b294b42af6db61446594a0dece",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291",
"reference": "045321c440ac18347b136c63d2e9bf28a2dc0291",
"shasum": ""
},
"require": {
@@ -3251,7 +3385,7 @@
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.4.0"
"source": "https://github.com/symfony/var-dumper/tree/v7.4.6"
},
"funding": [
{
@@ -3271,7 +3405,7 @@
"type": "tidelift"
}
],
"time": "2025-10-27T20:36:44+00:00"
"time": "2026-02-15T10:53:20+00:00"
},
{
"name": "symfony/var-exporter",
@@ -3356,16 +3490,16 @@
},
{
"name": "symfony/yaml",
"version": "v7.4.1",
"version": "v7.4.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
"reference": "58751048de17bae71c5aa0d13cb19d79bca26391"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
"url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391",
"reference": "58751048de17bae71c5aa0d13cb19d79bca26391",
"shasum": ""
},
"require": {
@@ -3408,7 +3542,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
"source": "https://github.com/symfony/yaml/tree/v7.4.6"
},
"funding": [
{
@@ -3428,20 +3562,20 @@
"type": "tidelift"
}
],
"time": "2025-12-04T18:11:45+00:00"
"time": "2026-02-09T09:33:46+00:00"
},
{
"name": "twig/twig",
"version": "v3.22.1",
"version": "v3.23.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "1de2ec1fc43ab58a4b7e80b214b96bfc895750f3"
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/1de2ec1fc43ab58a4b7e80b214b96bfc895750f3",
"reference": "1de2ec1fc43ab58a4b7e80b214b96bfc895750f3",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"shasum": ""
},
"require": {
@@ -3495,7 +3629,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.22.1"
"source": "https://github.com/twigphp/Twig/tree/v3.23.0"
},
"funding": [
{
@@ -3507,7 +3641,7 @@
"type": "tidelift"
}
],
"time": "2025-11-16T16:01:12+00:00"
"time": "2026-01-23T21:00:41+00:00"
}
],
"packages-dev": [],
+9 -33
View File
@@ -2,38 +2,14 @@ framework:
cache:
app: cache.adapter.filesystem
pools:
noncePool:
adapters:
- cache.adapter.apcu
sessionPool:
adapters:
- cache.adapter.apcu
requestPool:
adapters:
- cache.adapter.apcu
persistSessionPool:
adapters:
- cache.adapter.filesystem
persistRequestPool:
adapters:
- cache.adapter.filesystem
nonceCache:
adapters: cache.adapter.apcu
rateLimitCache:
adapters: cache.adapter.apcu
sessionCache:
adapters: cache.adapter.apcu
sessionStorage:
adapters: cache.adapter.filesystem
# Unique name of your app: used to compute
# stable namespaces for cache keys.
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: digitaladapt/preauth
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads
# as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null
-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:
+2
View File
@@ -10,8 +10,10 @@ twig:
return_field: '%env(QUERY_PREFIX)%return'
id_field: '%env(QUERY_PREFIX)%id'
token_field: '%env(QUERY_PREFIX)%token'
password_field: '%env(QUERY_PREFIX)%password'
id_name: '%env(ID_NAME)%'
token_name: '%env(TOKEN_NAME)%'
password_name: '%env(PASSWORD_NAME)%'
submit_name: '%env(SUBMIT_NAME)%'
error_message: '%env(ERROR_MESSAGE)%'
teapot_title: '%env(TEAPOT_TITLE)%'
+404 -407
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
+6 -15
View File
@@ -16,23 +16,18 @@ parameters:
# 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
# default is the lower of 2 per half-minute or 10 per hour
env(BURST_COUNT): 2 # 2 per 30 seconds
env(BURST_TIME): 30 # seconds
env(UPPER_COUNT): 10 # 10 per hour
env(UPPER_TIME): 3600 # seconds (1 hour)
# --- 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
# how long do we allow all traffic from an ip address after successful login
# could be useful if you have a system which does not handle cookies
env(IP_TTL): '0' # default disabled, time in seconds
# if desired, in addition to supporting a TOTP, you can set a static password
env(STATIC_SECRET): '' # default disabled
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
@@ -43,6 +38,7 @@ parameters:
env(ERROR_COLOR): '#ffb16d'
env(ID_NAME): 'Session ID'
env(TOKEN_NAME): 'Authentication Token'
env(PASSWORD_NAME): 'Authentication Password'
env(SUBMIT_NAME): 'Submit'
env(ERROR_MESSAGE): 'Unsuccessful login attempt'
# title and message to use on block page, if teapot is true
@@ -53,15 +49,10 @@ parameters:
env(TOO_MANY_MESSAGE): 'Try again later'
app.cookie_ttl: '%env(COOKIE_TTL)%'
app.limit: '%env(LIMIT)%'
app.limit_timeout: '%env(LIMIT_TIMEOUT)%'
app.limit_ttl: '%env(LIMIT_TTL)%'
app.query_prefix: '%env(QUERY_PREFIX)%'
app.totp_uri: '%env(TOTP_URI)%'
app.assets: '%env(ASSETS)%'
app.ip_ttl: '%env(IP_TTL)%'
app.static_secret: '%env(STATIC_SECRET)%'
app.teapot: '%env(TEAPOT)%'
app.error_message: '%env(ERROR_MESSAGE)%'
+6 -11
View File
@@ -11,14 +11,11 @@
# NOTE: 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
#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
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
#BURST_COUNT=2 # 2 per 30 seconds
#BURST_TIME=30 # seconds
#UPPER_COUNT=10 # 10 per hour
#UPPER_TIME=3600 # seconds (1 hour)
# --- Extra Options ---
@@ -26,9 +23,6 @@
# could be useful if you have a system which does not handle cookies
#IP_TTL=0 # default disabled, time in seconds
# if desired, in addition to supporting a TOTP, you can set a static password
#STATIC_SECRET='' # deafult disabled
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
@@ -39,6 +33,7 @@
#ERROR_COLOR='#ffb16d' # apricot (light orange)
#ID_NAME='Session ID'
#TOKEN_NAME='Authentication Token'
PASSWORD_NAME='Authentication Password'
#SUBMIT_NAME='Submit'
#ERROR_MESSAGE='Unsuccessful login attempt'
# title and message to use on block page, if teapot is true
+28 -13
View File
@@ -1,34 +1,49 @@
# 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.
When someone tries to reach your protected web service, Caddy will check with preauth if they are allowed, if their preauth cookie is missing, invalid, or expired, we will show them to a login screen.
I say login, but it's really just a TOTP code (6 digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service.
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
I say login, but it's really just a TOTP code (6-digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
First time you spin up the docker container it will generate a TOTP secret (which you'll load into your authenticator app); or generate you own.
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
### History
#### 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.
-36
View File
@@ -10,14 +10,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ConfigBag {
private ClockInterface $clock;
private int $cookieTtl;
private int $limit;
private int $limitTimeout;
private int $limitTtl;
private string $queryPrefix;
private string $totpUri;
private ?string $assetsDir;
private ?int $ipTtl;
private ?string $staticSecret;
private bool $teapot;
private string $errorMessage;
private string $teapotTitle;
@@ -28,15 +23,9 @@ final readonly class ConfigBag {
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.static_secret%')] ?string $staticSecret,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
@@ -44,14 +33,9 @@ final readonly class ConfigBag {
) {
$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->staticSecret = $staticSecret ?: null;
$this->teapot = $teapot;
$this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle;
@@ -66,18 +50,6 @@ final readonly class ConfigBag {
return $this->cookieTtl;
}
public function limit(): int {
return $this->limit;
}
public function limitTimeout(): int {
return $this->limitTimeout;
}
public function limitTtl(): int {
return $this->limitTtl;
}
public function query(string $field): string {
return "$this->queryPrefix$field";
}
@@ -86,18 +58,10 @@ final readonly class ConfigBag {
return $this->totpUri;
}
public function assetsDir(): ?string {
return $this->assetsDir;
}
public function ipTtl(): ?int {
return $this->ipTtl;
}
public function staticSecret(): ?string {
return $this->staticSecret;
}
public function teapot(): bool {
return $this->teapot;
}
+8 -24
View File
@@ -8,12 +8,11 @@ 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 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 */
@@ -31,12 +30,10 @@ final class Payload {
}
public static function create(object $data): ?Payload {
/* if missing required fields id or nonce */
/* if missing required fields id, nonce, or token */
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)
strlen($data->token ?? '') < 1
) {
/* returns null as the input is invalid */
return null;
@@ -47,25 +44,12 @@ final class Payload {
$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;
}
$payload->token = $data->token;
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;
+5 -5
View File
@@ -4,21 +4,21 @@ declare(strict_types=1);
namespace App\Listener;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final readonly class AcceptListener {
use CookieNameTrait;
use HasLoggerTrait;
use StringTrait;
public function __construct(
private CacheItemPoolInterface $sessionPool,
private LoggerInterface $logger,
private CacheItemPoolInterface $sessionCache,
) {}
/** @throws InvalidArgumentException */
@@ -28,9 +28,9 @@ final readonly class AcceptListener {
if ($event->getRequest()->cookies->has($this->cookieName())) {
$cookie = $event->getRequest()->cookies->get($this->cookieName());
$cookieKey = $this->makeCacheKey("cookie_$cookie");
if ($this->sessionPool->hasItem($cookieKey)) {
if ($this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */
$id = $this->sessionPool->getItem($cookieKey)->get();
$id = $this->sessionCache->getItem($cookieKey)->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse(new Response("hi $id",
headers: ['Content-Type' => 'text/plain']
+5 -5
View File
@@ -4,21 +4,21 @@ declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final readonly class AllowListener {
use HasLoggerTrait;
use StringTrait;
public function __construct(
private CacheItemPoolInterface $sessionPool,
private CacheItemPoolInterface $sessionCache,
private ConfigBag $config,
private LoggerInterface $logger,
) {}
/** @throws InvalidArgumentException */
@@ -26,9 +26,9 @@ final readonly class AllowListener {
public function onKernelRequest(RequestEvent $event): void {
if ($this->config->ipTtl() > 0) {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
if ($this->sessionPool->hasItem($ipKey)) {
if ($this->sessionCache->hasItem($ipKey)) {
/* ip address corresponds to valid existing session */
$id = $this->sessionPool->getItem($ipKey)->get();
$id = $this->sessionCache->getItem($ipKey)->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse(new Response("hi $id",
headers: ['Content-Type' => 'text/plain']
+6 -12
View File
@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -17,18 +16,13 @@ use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class InterceptListener {
use HasLoggerTrait;
use MakeNonceTrait;
public function __construct(
private CacheItemPoolInterface $requestPool,
private ConfigBag $config,
private Environment $twig,
CacheItemPoolInterface $noncePool,
LoggerInterface $logger,
) {
$this->logger = $logger;
$this->noncePool = $noncePool;
}
private ConfigBag $config,
private Environment $twig,
) {}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
#[AsEventListener(priority: 55)]
@@ -39,7 +33,7 @@ final readonly class InterceptListener {
* 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(),
'nonce' => $this->makeNonce(),
]);
$event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html']
+32 -99
View File
@@ -3,24 +3,24 @@ 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\GetTotpTrait;
use App\Trait\HasLoggerTrait;
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\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Uid\Ulid;
use Twig\Environment;
use Twig\Error\LoaderError;
@@ -29,25 +29,22 @@ use Twig\Error\SyntaxError;
final readonly class LoginListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
use GetTotpTrait;
private CacheItemPoolInterface $requestPool;
private CacheItemPoolInterface $sessionPool;
private CacheItemPoolInterface $sessionCache;
private RateLimiterFactoryInterface $rateLimiter;
/** @throws InvalidArgumentException */
public function __construct(
private ConfigBag $config,
private Environment $twig,
CacheItemPoolInterface $noncePool,
CacheItemPoolInterface $requestPool,
CacheItemPoolInterface $sessionPool,
LoggerInterface $logger,
private Environment $twig,
CacheItemPoolInterface $sessionCache,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->requestPool = new MonitorCacheKeys($requestPool);
$this->sessionPool = new MonitorCacheKeys($sessionPool);
$this->noncePool = $noncePool;
$this->logger = $logger;
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->rateLimiter = $rateLimiter;
}
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
@@ -57,13 +54,8 @@ final readonly class LoginListener {
$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);
}
if ($payload && $payload->token) {
$response = $this->checkToken($payload, $event->getRequest());
}
/* token or password authentication was successful */
@@ -72,10 +64,7 @@ final readonly class LoginListener {
return;
}
$limitReached = $this->logFailure(
$payload ? $payload->toString() : $data,
$event->getRequest()
);
$limitReached = $this->logFailure($event->getRequest());
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true));
@@ -94,12 +83,12 @@ final readonly class LoginListener {
/* token is correct */
/* if server nonce is found and is valid */
$nonceItem = $this->noncePool->getItem($payload->nonce);
$nonceItem = $this->nonceCache->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);
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* token authentication successful, grant access and set response */
$cleanId = $this->makeCacheKey($payload->id);
@@ -143,41 +132,11 @@ final readonly class LoginListener {
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(
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid")
);
if ($sessionCookie->isHit()) {
@@ -187,7 +146,7 @@ final readonly class LoginListener {
}
$sessionCookie->set($id);
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionPool->save($sessionCookie);
$this->sessionCache->save($sessionCookie);
return Cookie::create(
name: $this->cookieName(),
@@ -203,29 +162,15 @@ final readonly class LoginListener {
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
$sessionIp = $this->sessionPool->getItem($ipKey);
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionPool->save($sessionIp);
$this->sessionCache->save($sessionIp);
}
/** @throws InvalidArgumentException */
private function logFailure(string $data, Request $request): bool {
// TODO use rate-limiting symfony system (also update RejectListener)
$timeframe = (int)floor(time() / $this->getTotp()->getPeriod());
/* hash the data and timeframe, so we do not count duplicates in the same timeframe
* hitting refresh a few times should not lock you out */
$ipKey = $this->makeCacheKey("ip_{$request->getClientIp()}");
$failuresItem = $this->requestPool->getItem($ipKey);
$failures = $failuresItem->get() ?? [];
$failures[hash('xxh3', "$timeframe-$data")] = true;
$limitReached = count($failures) >= $this->config->limit();
$failuresItem->set($failures);
$failuresItem->expiresAfter($limitReached
? $this->config->limitTtl() : $this->config->limitTimeout()
);
$this->requestPool->save($failuresItem);
return $limitReached;
private function logFailure(Request $request): bool {
$limiter = $this->rateLimiter->create($request->getClientIp());
return ($limiter->consume(1)->getRemainingTokens() < 1);
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
@@ -239,31 +184,19 @@ final readonly class LoginListener {
$status = Response::HTTP_UNAUTHORIZED;
$message = $this->config->errorMessage();
}
$answer = [
'message' => $message,
'nonce' => $this->makeNonce(),
];
if ($json) {
$contentType = 'application/json';
$content = json_encode([
'message' => $message,
'nonce' => $this->makeNonce(),
]);
$content = json_encode($answer);
} else {
$contentType = 'text/html';
$content = $this->twig->render('login.html.twig', [
'error_message' => $message,
'nonce_value' => $this->makeNonce(),
]);
$content = $this->twig->render('login.html.twig', $answer);
}
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');
}
}
+22 -23
View File
@@ -4,45 +4,44 @@ declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class RejectListener {
use HasLoggerTrait;
use StringTrait;
public function __construct(
private CacheItemPoolInterface $requestPool,
private ConfigBag $config,
private Environment $twig,
private LoggerInterface $logger,
) {}
private RateLimiterFactoryInterface $rateLimiter;
/** @throws SyntaxError|InvalidArgumentException|RuntimeError|LoaderError */
public function __construct(
private ConfigBag $config,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
}
/** @throws SyntaxError|RuntimeError|LoaderError */
#[AsEventListener(priority: 77)]
public function onKernelRequest(RequestEvent $event): void {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
/* check if they have made too many failed login attempts */
$failuresItem = $this->requestPool->getItem($ipKey);
if ($failuresItem->isHit()) {
$failures = $failuresItem->get() ?? [];
if (count($failures) >= $this->config->limit()) {
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response($html, ($this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
['Content-Type' => 'text/html']
));
}
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
if ($limiter->consume(0)->getRemainingTokens() < 1) {
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response($html, ($this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
['Content-Type' => 'text/html']
));
}
}
}
+60 -47
View File
@@ -12,14 +12,16 @@ use Psr\Cache\InvalidArgumentException;
* because it can change from outside this object instance. */
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
private const KEY_LIST = '__key_list';
private const IS_DIRTY = '__is_dirty';
private const CHANGE_LIST = '__chg_list';
public const UPDATED = 1;
public const REMOVED = 2;
private CacheItemPoolInterface $cache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $cache) {
$this->cache = $cache;
$items = $cache->getItems([self::KEY_LIST, self::IS_DIRTY]);
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
foreach ($items as $item) {
if ( ! $item->isHit()) {
$this->initialize();
@@ -31,11 +33,11 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
/** @throws InvalidArgumentException */
private function initialize(): void {
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$keyList->set([]);
$isDirty->set(false);
$changeList->set([]);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->cache->saveDeferred($changeList);
$this->cache->commit();
}
@@ -46,22 +48,24 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
public function isDirty(): bool {
$isDirty = $this->cache->getItem(self::IS_DIRTY);
return $isDirty->get() ?? false;
public function getChanges(): array {
$changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? [];
}
/** @throws InvalidArgumentException */
public function markClean(): void {
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$isDirty->set(false);
$this->cache->save($isDirty);
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeList->set([]);
$this->cache->save($changeList);
}
public function getItem(string $key): CacheItemInterface {
return $this->cache->getItem($key);
}
/** @return CacheItemInterface[]
* @throws InvalidArgumentException */
public function getItems(array $keys = []): iterable {
return $this->cache->getItems($keys);
}
@@ -83,20 +87,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
public function deleteItem(string $key): bool {
if ($key === self::KEY_LIST || $key === self::IS_DIRTY) {
throw new OutOfBoundsException(
'Can not delete the private key list or is dirty flag'
);
}
$this->isValid($key);
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$keyValues = $keyList->get();
if (isset($keyValues[$key])) {
unset($keyValues[$key]);
$keyList->set($keyValues);
$isDirty->set(true);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->logChange($key, MonitorCacheKeys::REMOVED);
$this->cache->commit();
}
@@ -104,25 +102,17 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
public function deleteItems(array $keys): bool {
if (in_array(self::KEY_LIST, $keys, true) ||
in_array(self::IS_DIRTY, $keys, true)
) {
throw new OutOfBoundsException(
'Can not delete the private key list or is dirty flag'
);
}
$this->allValid($keys);
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$keyValues = $keyList->get();
foreach ($keys as $key) {
if (isset($keyValues[$key])) {
unset($keyValues[$key]);
$isDirty->set(true);
$this->logChange($key, MonitorCacheKeys::REMOVED);
}
}
$keyList->set($keyValues);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->cache->commit();
return $this->cache->deleteItems($keys);
@@ -140,25 +130,48 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return $this->cache->saveDeferred($item);
}
/** @throws InvalidArgumentException */
private function update(CacheItemInterface $item) {
if ($item->getKey() === self::KEY_LIST || $item->getKey() === self::IS_DIRTY) {
throw new OutOfBoundsException(
'Can not alter the private key list or is dirty flag'
);
}
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$keyValues = $keyList->get();
$keyValues[$item->getKey()] = true;
$keyList->set($keyValues);
$isDirty->set(true);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->cache->commit();
}
public function commit(): bool {
return $this->cache->commit();
}
/** @throws InvalidArgumentException|OutOfBoundsException */
private function update(CacheItemInterface $item) {
$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);
}
}
+22 -43
View File
@@ -9,67 +9,46 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
#[Autoconfigure(public: true)]
final readonly class PersistCache {
private MonitorCacheKeys $requestPool;
private MonitorCacheKeys $persistRequestPool;
private MonitorCacheKeys $sessionPool;
private MonitorCacheKeys $persistSessionPool;
private MonitorCacheKeys $sessionCache;
private MonitorCacheKeys $sessionStorage;
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $requestPool,
CacheItemPoolInterface $persistRequestPool,
CacheItemPoolInterface $sessionPool,
CacheItemPoolInterface $persistSessionPool
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $sessionStorage,
) {
$this->requestPool = new MonitorCacheKeys($requestPool);
$this->persistRequestPool = new MonitorCacheKeys($persistRequestPool);
$this->sessionPool = new MonitorCacheKeys($sessionPool);
$this->persistSessionPool = new MonitorCacheKeys($persistSessionPool);
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
}
/** @throws InvalidArgumentException */
public function boot(): void {
/* the caches are considered warm as soon as they are not empty */
if (empty($this->requestPool->getKeys())) {
$items = $this->persistRequestPool->getItems($this->persistRequestPool->getKeys());
if (empty($this->sessionCache->getKeys())) {
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
foreach ($items as $item) {
$this->requestPool->saveDeferred($item);
$this->sessionCache->saveDeferred($item);
}
$this->requestPool->markClean();
$this->requestPool->commit();
}
if (empty($this->sessionPool->getKeys())) {
$items = $this->persistSessionPool->getItems($this->persistSessionPool->getKeys());
foreach ($items as $item) {
$this->sessionPool->saveDeferred($item);
}
$this->sessionPool->markClean();
$this->sessionPool->commit();
$this->sessionCache->markClean();
$this->sessionCache->commit();
}
}
/** @throws InvalidArgumentException */
public function persist(): void {
/* we only need to persist the caches if they contain changes */
if ($this->requestPool->isDirty()) {
$this->requestPool->markClean();
$items = $this->requestPool->getItems($this->requestPool->getKeys());
$this->persistRequestPool->clear();
/* we only need to persist the changes made to the cache (if any) */
$changes = $this->sessionCache->getChanges();
if ($changes) {
$this->sessionCache->markClean();
$items = $this->sessionCache->getItems(array_keys($changes));
foreach ($items as $item) {
$this->persistRequestPool->saveDeferred($item);
if (($changes[$item->getKey()] ?? null) === MonitorCacheKeys::REMOVED) {
$this->sessionStorage->deleteItem($item->getKey());
} else {
$this->sessionStorage->saveDeferred($item);
}
}
$this->persistRequestPool->commit();
}
if ($this->sessionPool->isDirty()) {
$this->sessionPool->markClean();
$items = $this->sessionPool->getItems($this->sessionPool->getKeys());
$this->persistSessionPool->clear();
foreach ($items as $item) {
$this->persistSessionPool->saveDeferred($item);
}
$this->persistSessionPool->commit();
$this->sessionStorage->commit();
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
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;
}
}
+12 -6
View File
@@ -6,17 +6,23 @@ namespace App\Trait;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait {
use HasLoggerTrait;
/* 15 bytes neatly fits in base64 */
private const NONCE_LENGTH = 15;
private const NONCE_TTL = 60;
private const NONCE_TTL = 120;
protected readonly CacheItemPoolInterface $noncePool;
protected readonly LoggerInterface $logger;
protected readonly CacheItemPoolInterface $nonceCache;
#[Required]
public function setNonceCache(CacheItemPoolInterface $nonceCache): void {
$this->nonceCache = $nonceCache;
}
/** @throws InvalidArgumentException|Exception */
protected function makeNonce(int $retries = 3): string {
@@ -24,7 +30,7 @@ trait MakeNonceTrait {
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
)), '+/', '-_'), '=');
$nonceItem = $this->noncePool->getItem($nonce);
$nonceItem = $this->nonceCache->getItem($nonce);
if ($nonceItem->isHit()) {
if ($retries < 1) {
@@ -41,7 +47,7 @@ trait MakeNonceTrait {
$nonceItem->set(true); /* valid */
$nonceItem->expiresAfter(static::NONCE_TTL);
$this->logger->debug("added nonce: $nonce");
$this->noncePool->save($nonceItem);
$this->nonceCache->save($nonceItem);
return $nonce;
}
}
@@ -1,3 +0,0 @@
0
__key_list
a:0:{}
@@ -1,3 +0,0 @@
0
__is_dirty
b:0;
@@ -1,3 +0,0 @@
0
__key_list
a:0:{}
@@ -1,3 +0,0 @@
0
__is_dirty
b:0;
@@ -1,3 +0,0 @@
1766103213
suppress
N;
@@ -1,3 +0,0 @@
32503594112
totp
s:138:"otpauth://totp/Preauth-TOTP?secret=5RL5FOJGV4XRKGT74ZVN4725OAM244SU7JYXYX4SHQDTJI4P3YKBYAFUVBBOCLI5XSOLERNB6IQQ54SIGY6QHJ26JM4OP3ZJVBUUBIY";
+52
View File
@@ -0,0 +1,52 @@
<script>
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
form.addEventListener('submit', (event) => {
event.preventDefault();
/* make base64url string containing our payload json object */
const data = btoa(JSON.stringify({
id: form.preauth_id.value,
token: form.preauth_token.value,
nonce: form.preauth_nonce.value,
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/* 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>
+8 -3
View File
@@ -5,8 +5,13 @@ html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display:
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>
+2 -1
View File
@@ -3,10 +3,11 @@
<head>
<meta charset="utf-8">
<title>{{ env.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{{ include('_style.html.twig') }}
</head>
<body>
{% block content %}{% endblock %}
{{ include('_script.html.twig') }}
</body>
</html>
+5 -57
View File
@@ -2,67 +2,15 @@
{% block content %}
<h1>{{ env.title }}</h1>
<p id="preauth-message">{{ error_message|default }}</p>
<p id="preauth-message">{{ message|default }}</p>
<form id="preauth-form">
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce_value }}">
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce }}">
<div class="right"><label for="preauth-id">{{ env.id_name }}:</label></div>
<div><input type="text" name="preauth_id" id="preauth-id"
autocomplete="username" required="required" autofocus="autofocus"></div>
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div>
<div><input type="text" name="preauth_token" id="preauth-token"
autocomplete="one-time-code" required="required"></div>
<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 %}