Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4c289d81 | ||
|
|
b36aabb8a3 | ||
|
|
f486ab7481 | ||
|
|
a73d039e10 | ||
|
|
102b9f3e78 |
@@ -1,3 +1,20 @@
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[Caddyfile]
|
||||
indent_style = tab
|
||||
|
||||
[{compose.yaml,compose.*.yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# required, if missing will generate random values
|
||||
# encryption key used to store sessions (static random bytes) in base64
|
||||
PREAUTH_KEY=''
|
||||
# TOTP (RFC 6238) secret/token (static random bytes) in base32
|
||||
PREAUTH_TOKEN=''
|
||||
|
||||
# optional, change time-to-live, subdomain, default-redirect, text or colors
|
||||
# how long a session lasts (in minutes): 43200 is 30 days
|
||||
PREAUTH_TTL=43200
|
||||
PREAUTH_SUBDOMAIN='preauth'
|
||||
PREAUTH_SEND_TO='https://secure.example.com/'
|
||||
PREAUTH_BACKGROUND='#029386'
|
||||
PREAUTH_FOREGROUND='#ffffff'
|
||||
PREAUTH_TITLE='Pre-Authentication System'
|
||||
PREAUTH_ID_NAME='Session ID'
|
||||
PREAUTH_TOKEN_NAME='Authentication Token'
|
||||
PREAUTH_SUBMIT_NAME='Submit'
|
||||
# how many consecutive failed login attempts before we block them (a remote-ip)
|
||||
PREAUTH_RATE_LIMIT=4
|
||||
# maximum time between failed login attempts to still be consecutive (in minutes): 360 is 6 hours
|
||||
PREAUTH_RATE_TIMEOUT=360
|
||||
# how long after last failed login will they be blocked (in minutes): 1440 is 24 hours
|
||||
PREAUTH_RATE_BLOCKED=1440
|
||||
# what do we show when they get rate-limited
|
||||
PREAUTH_DENIED_CODE=418
|
||||
PREAUTH_DENIED_TITLE="I'm a teapot"
|
||||
PREAUTH_DENIED_MESSAGE='I refuse to brew coffee.'
|
||||
# alternatively, you could use a more standard response
|
||||
#PREAUTH_DENIED_CODE=429
|
||||
#PREAUTH_DENIED_TITLE='Too Many Requests'
|
||||
#PREAUTH_DENIED_MESSAGE='Try again later.'
|
||||
|
||||
# who owns the session files
|
||||
# permissions of the volume must match
|
||||
USER_ID=1000
|
||||
GROUP_ID=1000
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
header("http/1.1 {$env->getDeniedCode()} {$env->getDeniedTitle()}", true, $env->getDeniedCode());
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo $env->getTitle(); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Begin Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
|
||||
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
|
||||
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
|
||||
<!-- End Icons -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
}
|
||||
html {
|
||||
background-color: <?php echo $env->getColor(); ?>;
|
||||
color: <?php echo $env->getTextColor(); ?>;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
font-size: 1.5em;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><?php echo $env->getTitle(); ?></h1>
|
||||
<h2><?php echo $env->getDeniedTitle(); ?></h2>
|
||||
<p><?php echo $env->getDeniedMessage(); ?></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo $env->getTitle(); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Begin Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
|
||||
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
|
||||
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
|
||||
<!-- End Icons -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
}
|
||||
html {
|
||||
background-color: <?php echo $env->getColor(); ?>;
|
||||
color: <?php echo $env->getTextColor(); ?>;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
font-size: 1.5em;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><?php echo $env->getTitle(); ?></h1>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p>Review the logs for more details.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +1,8 @@
|
||||
# allow the pre-auth page to work
|
||||
preauth.example.com {
|
||||
reverse_proxy preauth:9000 {
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
header_down -X-Powered-By
|
||||
rewrite /preauth.php
|
||||
transport fastcgi {
|
||||
root /preauth/
|
||||
split .php
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# if using caddy v2.9.x+ you can use this snippet
|
||||
# snippet to put the pre-auth system in front any service easily
|
||||
(preauth) {
|
||||
reverse_proxy {args[0]} preauth:9000 {
|
||||
method GET
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
header_down -X-Powered-By
|
||||
rewrite /preauth.php
|
||||
transport fastcgi {
|
||||
root /preauth/
|
||||
split .php
|
||||
}
|
||||
@preauth_ok status 2xx
|
||||
handle_response @preauth_ok {
|
||||
copy_response_headers {
|
||||
include Set-Cookie Location
|
||||
}
|
||||
{block}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# snippet usage: this will cause preauth to restrict access to
|
||||
# https://protected.example.com/secure
|
||||
protected.example.com {
|
||||
import preauth /secure {
|
||||
reverse_proxy protected-service:9000
|
||||
}
|
||||
reverse_proxy exposed-service:9000
|
||||
http://
|
||||
root public/
|
||||
rewrite index.php
|
||||
php {
|
||||
root /app/public
|
||||
worker index.php
|
||||
}
|
||||
|
||||
|
||||
+53
-22
@@ -1,34 +1,65 @@
|
||||
FROM php:8.4-fpm-alpine
|
||||
# use build image, to simplify final image
|
||||
FROM php:8.4-trixie AS build
|
||||
|
||||
# get compose so we can install our php dependencies
|
||||
# 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
|
||||
|
||||
# app is stored here
|
||||
RUN mkdir /preauth
|
||||
WORKDIR /preauth
|
||||
COPY . /preauth/
|
||||
# symfony required environment variables
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_DEBUG=0
|
||||
ENV DEFAULT_URI='http://'
|
||||
|
||||
# add php config for rate limit monitoring
|
||||
RUN mkdir -p /usr/local/etc/php/conf.d
|
||||
COPY preauth-php.ini /usr/local/etc/php/conf.d/preauth-php.ini
|
||||
# load application into build image
|
||||
RUN mkdir -p /app/bin
|
||||
WORKDIR /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
|
||||
|
||||
# login sessions are stored here
|
||||
RUN mkdir -p /tmp/data/sessions
|
||||
# install application dependencies
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
RUN composer dump-env prod --empty
|
||||
|
||||
# rate limit monitoring information is stored here
|
||||
RUN mkdir -p /tmp/data/monitor
|
||||
# start creating final image
|
||||
FROM dunglas/frankenphp:php8.4-trixie
|
||||
|
||||
# fcgi command for the healthcheck
|
||||
RUN apk add fcgi
|
||||
# install APCu
|
||||
RUN pecl install apcu && \
|
||||
docker-php-ext-enable apcu
|
||||
|
||||
# install our php dependencies
|
||||
RUN composer install
|
||||
# symfony required environment variables
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_DEBUG=0
|
||||
ENV DEFAULT_URI='http://'
|
||||
|
||||
EXPOSE 9000
|
||||
# load application into final image
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
|
||||
HEALTHCHECK --interval=5m --retries=3 --start-interval=5s --start-period=50s --timeout=5s \
|
||||
CMD SCRIPT_NAME=/health.php SCRIPT_FILENAME=/preauth/health.php REQUEST_METHOD=GET \
|
||||
cgi-fcgi -bind -connect localhost:9000 | grep 'online' || exit 1
|
||||
# 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
|
||||
|
||||
ENTRYPOINT ["/preauth/init.sh"]
|
||||
# app uses var folder for cache storage
|
||||
VOLUME ["/app/var"]
|
||||
|
||||
# runs http on standard port
|
||||
EXPOSE 80
|
||||
|
||||
# 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.
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||
}
|
||||
|
||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
||||
return new Application($kernel);
|
||||
};
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# TODO rename ".env.example" to just ".env"
|
||||
# edit USER_ID and GROUP_ID if needed
|
||||
# set PREAUTH_KEY and PREAUTH_TOKEN after initial start to persist those settings
|
||||
- .env
|
||||
expose:
|
||||
- 9000
|
||||
image: digitaladapt/preauth
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
user: ${USER_ID}:${GROUP_ID}
|
||||
volumes:
|
||||
- preauth:/tmp/data
|
||||
|
||||
volumes:
|
||||
preauth:
|
||||
|
||||
+69
-5
@@ -1,12 +1,76 @@
|
||||
{
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"spomky-labs/otphp": "^11.3",
|
||||
"symfony/cache": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/yaml": "7.4.*"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true,
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"bump-after-update": true,
|
||||
"sort-packages": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Preauth\\": "src/"
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"spomky-labs/otphp": "^11.3",
|
||||
"symfony/http-foundation": "^7.3",
|
||||
"symfony/uid": "^7.3"
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php72": "*",
|
||||
"symfony/polyfill-php73": "*",
|
||||
"symfony/polyfill-php74": "*",
|
||||
"symfony/polyfill-php80": "*",
|
||||
"symfony/polyfill-php81": "*",
|
||||
"symfony/polyfill-php82": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"runtime": {
|
||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
},
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3526
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,18 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
|
||||
trusted_proxies: 'private_ranges'
|
||||
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto']
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
@@ -0,0 +1,12 @@
|
||||
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)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
@@ -0,0 +1,20 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
strict_variables: true
|
||||
globals:
|
||||
env:
|
||||
title: '%env(TITLE)%'
|
||||
bg_color: '%env(BG_COLOR)%'
|
||||
fg_color: '%env(FG_COLOR)%'
|
||||
error_color: '%env(ERROR_COLOR)%'
|
||||
return_field: '%env(QUERY_PREFIX)%return'
|
||||
id_field: '%env(QUERY_PREFIX)%id'
|
||||
token_field: '%env(QUERY_PREFIX)%token'
|
||||
id_name: '%env(ID_NAME)%'
|
||||
token_name: '%env(TOKEN_NAME)%'
|
||||
submit_name: '%env(SUBMIT_NAME)%'
|
||||
error_message: '%env(ERROR_MESSAGE)%'
|
||||
teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
teapot_message: '%env(TEAPOT_MESSAGE)%'
|
||||
too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
too_many_message: '%env(TOO_MANY_MESSAGE)%'
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__) .
|
||||
'/var/cache/prod/App_KernelProdContainer.preload.php')
|
||||
) {
|
||||
require dirname(__DIR__) .
|
||||
'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
<?php
|
||||
|
||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
|
||||
/**
|
||||
* This class provides array-shapes for configuring the services and bundles of an application.
|
||||
*
|
||||
* Services declared with the config() method below are autowired and autoconfigured by default.
|
||||
*
|
||||
* This is for apps only. Bundles SHOULD NOT use it.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```php
|
||||
* // config/services.php
|
||||
* namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
*
|
||||
* return App::config([
|
||||
* 'services' => [
|
||||
* 'App\\' => [
|
||||
* 'resource' => '../src/',
|
||||
* ],
|
||||
* ],
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @psalm-type ImportsConfig = list<string|array{
|
||||
* resource: string,
|
||||
* type?: string|null,
|
||||
* ignore_errors?: bool,
|
||||
* }>
|
||||
* @psalm-type ParametersConfig = array<string, scalar|\UnitEnum|array<scalar|\UnitEnum|array<mixed>|null>|null>
|
||||
* @psalm-type ArgumentsType = list<mixed>|array<string, mixed>
|
||||
* @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}
|
||||
* @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key
|
||||
* @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator
|
||||
* @psalm-type DeprecationType = array{package: string, version: string, message?: string}
|
||||
* @psalm-type DefaultsType = array{
|
||||
* public?: bool,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* }
|
||||
* @psalm-type InstanceofType = array{
|
||||
* shared?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* properties?: array<string, mixed>,
|
||||
* configurator?: CallbackType,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* }
|
||||
* @psalm-type DefinitionType = array{
|
||||
* class?: string,
|
||||
* file?: string,
|
||||
* parent?: string,
|
||||
* shared?: bool,
|
||||
* synthetic?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* abstract?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* factory?: CallbackType,
|
||||
* configurator?: CallbackType,
|
||||
* arguments?: ArgumentsType,
|
||||
* properties?: array<string, mixed>,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* decorates?: string,
|
||||
* decoration_inner_name?: string,
|
||||
* decoration_priority?: int,
|
||||
* decoration_on_invalid?: 'exception'|'ignore'|null,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* from_callable?: CallbackType,
|
||||
* }
|
||||
* @psalm-type AliasType = string|array{
|
||||
* alias: string,
|
||||
* public?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* }
|
||||
* @psalm-type PrototypeType = array{
|
||||
* resource: string,
|
||||
* namespace?: string,
|
||||
* exclude?: string|list<string>,
|
||||
* parent?: string,
|
||||
* shared?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* abstract?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* factory?: CallbackType,
|
||||
* arguments?: ArgumentsType,
|
||||
* properties?: array<string, mixed>,
|
||||
* configurator?: CallbackType,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* }
|
||||
* @psalm-type StackType = array{
|
||||
* stack: list<DefinitionType|AliasType|PrototypeType|array<class-string, ArgumentsType|null>>,
|
||||
* public?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* }
|
||||
* @psalm-type ServicesConfig = array{
|
||||
* _defaults?: DefaultsType,
|
||||
* _instanceof?: InstanceofType,
|
||||
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
|
||||
* }
|
||||
* @psalm-type ExtensionType = array<string, mixed>
|
||||
* @psalm-type FrameworkConfig = array{
|
||||
* secret?: scalar|null,
|
||||
* http_method_override?: bool, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false
|
||||
* allowed_http_method_override?: list<string>|null,
|
||||
* trust_x_sendfile_type_header?: scalar|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%"
|
||||
* ide?: scalar|null, // Default: "%env(default::SYMFONY_IDE)%"
|
||||
* test?: bool,
|
||||
* default_locale?: scalar|null, // Default: "en"
|
||||
* set_locale_from_accept_language?: bool, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false
|
||||
* set_content_language_from_locale?: bool, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false
|
||||
* enabled_locales?: list<scalar|null>,
|
||||
* trusted_hosts?: list<scalar|null>,
|
||||
* trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"]
|
||||
* trusted_headers?: list<scalar|null>,
|
||||
* error_controller?: scalar|null, // Default: "error_controller"
|
||||
* handle_all_throwables?: bool, // HttpKernel will handle all kinds of \Throwable. // Default: true
|
||||
* csrf_protection?: bool|array{
|
||||
* enabled?: scalar|null, // Default: null
|
||||
* stateless_token_ids?: list<scalar|null>,
|
||||
* check_header?: scalar|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false
|
||||
* cookie_name?: scalar|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token"
|
||||
* },
|
||||
* form?: bool|array{ // Form configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* csrf_protection?: array{
|
||||
* enabled?: scalar|null, // Default: null
|
||||
* token_id?: scalar|null, // Default: null
|
||||
* field_name?: scalar|null, // Default: "_token"
|
||||
* field_attr?: array<string, scalar|null>,
|
||||
* },
|
||||
* },
|
||||
* http_cache?: bool|array{ // HTTP cache configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* debug?: bool, // Default: "%kernel.debug%"
|
||||
* trace_level?: "none"|"short"|"full",
|
||||
* trace_header?: scalar|null,
|
||||
* default_ttl?: int,
|
||||
* private_headers?: list<scalar|null>,
|
||||
* skip_response_headers?: list<scalar|null>,
|
||||
* allow_reload?: bool,
|
||||
* allow_revalidate?: bool,
|
||||
* stale_while_revalidate?: int,
|
||||
* stale_if_error?: int,
|
||||
* terminate_on_cache_hit?: bool,
|
||||
* },
|
||||
* esi?: bool|array{ // ESI configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* ssi?: bool|array{ // SSI configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* fragments?: bool|array{ // Fragments configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* hinclude_default_template?: scalar|null, // Default: null
|
||||
* path?: scalar|null, // Default: "/_fragment"
|
||||
* },
|
||||
* profiler?: bool|array{ // Profiler configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* collect?: bool, // Default: true
|
||||
* collect_parameter?: scalar|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null
|
||||
* only_exceptions?: bool, // Default: false
|
||||
* only_main_requests?: bool, // Default: false
|
||||
* dsn?: scalar|null, // Default: "file:%kernel.cache_dir%/profiler"
|
||||
* collect_serializer_data?: bool, // Enables the serializer data collector and profiler panel. // Default: false
|
||||
* },
|
||||
* workflows?: bool|array{
|
||||
* enabled?: bool, // Default: false
|
||||
* workflows?: array<string, array{ // Default: []
|
||||
* audit_trail?: bool|array{
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* type?: "workflow"|"state_machine", // Default: "state_machine"
|
||||
* marking_store?: array{
|
||||
* type?: "method",
|
||||
* property?: scalar|null,
|
||||
* service?: scalar|null,
|
||||
* },
|
||||
* supports?: list<scalar|null>,
|
||||
* definition_validators?: list<scalar|null>,
|
||||
* support_strategy?: scalar|null,
|
||||
* initial_marking?: list<scalar|null>,
|
||||
* events_to_dispatch?: list<string>|null,
|
||||
* places?: list<array{ // Default: []
|
||||
* name: scalar|null,
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* transitions: list<array{ // Default: []
|
||||
* name: string,
|
||||
* guard?: string, // An expression to block the transition.
|
||||
* from?: list<array{ // Default: []
|
||||
* place: string,
|
||||
* weight?: int, // Default: 1
|
||||
* }>,
|
||||
* to?: list<array{ // Default: []
|
||||
* place: string,
|
||||
* weight?: int, // Default: 1
|
||||
* }>,
|
||||
* weight?: int, // Default: 1
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* },
|
||||
* router?: bool|array{ // Router configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* resource: scalar|null,
|
||||
* type?: scalar|null,
|
||||
* cache_dir?: scalar|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
|
||||
* default_uri?: scalar|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||
* http_port?: scalar|null, // Default: 80
|
||||
* https_port?: scalar|null, // Default: 443
|
||||
* strict_requirements?: scalar|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true
|
||||
* utf8?: bool, // Default: true
|
||||
* },
|
||||
* session?: bool|array{ // Session configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* storage_factory_id?: scalar|null, // Default: "session.storage.factory.native"
|
||||
* handler_id?: scalar|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null.
|
||||
* name?: scalar|null,
|
||||
* cookie_lifetime?: scalar|null,
|
||||
* cookie_path?: scalar|null,
|
||||
* cookie_domain?: scalar|null,
|
||||
* cookie_secure?: true|false|"auto", // Default: "auto"
|
||||
* cookie_httponly?: bool, // Default: true
|
||||
* cookie_samesite?: null|"lax"|"strict"|"none", // Default: "lax"
|
||||
* use_cookies?: bool,
|
||||
* gc_divisor?: scalar|null,
|
||||
* gc_probability?: scalar|null,
|
||||
* gc_maxlifetime?: scalar|null,
|
||||
* save_path?: scalar|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null.
|
||||
* metadata_update_threshold?: int, // Seconds to wait between 2 session metadata updates. // Default: 0
|
||||
* sid_length?: int, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||
* sid_bits_per_character?: int, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||
* },
|
||||
* request?: bool|array{ // Request configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* formats?: array<string, string|list<scalar|null>>,
|
||||
* },
|
||||
* assets?: bool|array{ // Assets configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* strict_mode?: bool, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||
* version_strategy?: scalar|null, // Default: null
|
||||
* version?: scalar|null, // Default: null
|
||||
* version_format?: scalar|null, // Default: "%%s?%%s"
|
||||
* json_manifest_path?: scalar|null, // Default: null
|
||||
* base_path?: scalar|null, // Default: ""
|
||||
* base_urls?: list<scalar|null>,
|
||||
* packages?: array<string, array{ // Default: []
|
||||
* strict_mode?: bool, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||
* version_strategy?: scalar|null, // Default: null
|
||||
* version?: scalar|null,
|
||||
* version_format?: scalar|null, // Default: null
|
||||
* json_manifest_path?: scalar|null, // Default: null
|
||||
* base_path?: scalar|null, // Default: ""
|
||||
* base_urls?: list<scalar|null>,
|
||||
* }>,
|
||||
* },
|
||||
* asset_mapper?: bool|array{ // Asset Mapper configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* paths?: array<string, scalar|null>,
|
||||
* excluded_patterns?: list<scalar|null>,
|
||||
* exclude_dotfiles?: bool, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true
|
||||
* server?: bool, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true
|
||||
* public_prefix?: scalar|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/"
|
||||
* missing_import_mode?: "strict"|"warn"|"ignore", // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn"
|
||||
* extensions?: array<string, scalar|null>,
|
||||
* importmap_path?: scalar|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php"
|
||||
* importmap_polyfill?: scalar|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims"
|
||||
* importmap_script_attributes?: array<string, scalar|null>,
|
||||
* vendor_dir?: scalar|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor"
|
||||
* precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip.
|
||||
* enabled?: bool, // Default: false
|
||||
* formats?: list<scalar|null>,
|
||||
* extensions?: list<scalar|null>,
|
||||
* },
|
||||
* },
|
||||
* translator?: bool|array{ // Translator configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* fallbacks?: list<scalar|null>,
|
||||
* logging?: bool, // Default: false
|
||||
* formatter?: scalar|null, // Default: "translator.formatter.default"
|
||||
* cache_dir?: scalar|null, // Default: "%kernel.cache_dir%/translations"
|
||||
* default_path?: scalar|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations"
|
||||
* paths?: list<scalar|null>,
|
||||
* pseudo_localization?: bool|array{
|
||||
* enabled?: bool, // Default: false
|
||||
* accents?: bool, // Default: true
|
||||
* expansion_factor?: float, // Default: 1.0
|
||||
* brackets?: bool, // Default: true
|
||||
* parse_html?: bool, // Default: false
|
||||
* localizable_html_attributes?: list<scalar|null>,
|
||||
* },
|
||||
* providers?: array<string, array{ // Default: []
|
||||
* dsn?: scalar|null,
|
||||
* domains?: list<scalar|null>,
|
||||
* locales?: list<scalar|null>,
|
||||
* }>,
|
||||
* globals?: array<string, string|array{ // Default: []
|
||||
* value?: mixed,
|
||||
* message?: string,
|
||||
* parameters?: array<string, scalar|null>,
|
||||
* domain?: string,
|
||||
* }>,
|
||||
* },
|
||||
* validation?: bool|array{ // Validation configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* cache?: scalar|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0.
|
||||
* enable_attributes?: bool, // Default: true
|
||||
* static_method?: list<scalar|null>,
|
||||
* translation_domain?: scalar|null, // Default: "validators"
|
||||
* email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose", // Default: "html5"
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|null>,
|
||||
* },
|
||||
* not_compromised_password?: bool|array{
|
||||
* enabled?: bool, // When disabled, compromised passwords will be accepted as valid. // Default: true
|
||||
* endpoint?: scalar|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null
|
||||
* },
|
||||
* disable_translation?: bool, // Default: false
|
||||
* auto_mapping?: array<string, array{ // Default: []
|
||||
* services?: list<scalar|null>,
|
||||
* }>,
|
||||
* },
|
||||
* annotations?: bool|array{
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* serializer?: bool|array{ // Serializer configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* enable_attributes?: bool, // Default: true
|
||||
* name_converter?: scalar|null,
|
||||
* circular_reference_handler?: scalar|null,
|
||||
* max_depth_handler?: scalar|null,
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|null>,
|
||||
* },
|
||||
* default_context?: list<mixed>,
|
||||
* named_serializers?: array<string, array{ // Default: []
|
||||
* name_converter?: scalar|null,
|
||||
* default_context?: list<mixed>,
|
||||
* include_built_in_normalizers?: bool, // Whether to include the built-in normalizers // Default: true
|
||||
* include_built_in_encoders?: bool, // Whether to include the built-in encoders // Default: true
|
||||
* }>,
|
||||
* },
|
||||
* property_access?: bool|array{ // Property access configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* magic_call?: bool, // Default: false
|
||||
* magic_get?: bool, // Default: true
|
||||
* magic_set?: bool, // Default: true
|
||||
* throw_exception_on_invalid_index?: bool, // Default: false
|
||||
* throw_exception_on_invalid_property_path?: bool, // Default: true
|
||||
* },
|
||||
* type_info?: bool|array{ // Type info configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* aliases?: array<string, scalar|null>,
|
||||
* },
|
||||
* property_info?: bool|array{ // Property info configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* with_constructor_extractor?: bool, // Registers the constructor extractor.
|
||||
* },
|
||||
* cache?: array{ // Cache configuration
|
||||
* prefix_seed?: scalar|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%"
|
||||
* app?: scalar|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem"
|
||||
* system?: scalar|null, // System related cache pools configuration. // Default: "cache.adapter.system"
|
||||
* directory?: scalar|null, // Default: "%kernel.share_dir%/pools/app"
|
||||
* default_psr6_provider?: scalar|null,
|
||||
* default_redis_provider?: scalar|null, // Default: "redis://localhost"
|
||||
* default_valkey_provider?: scalar|null, // Default: "valkey://localhost"
|
||||
* default_memcached_provider?: scalar|null, // Default: "memcached://localhost"
|
||||
* default_doctrine_dbal_provider?: scalar|null, // Default: "database_connection"
|
||||
* default_pdo_provider?: scalar|null, // Default: null
|
||||
* pools?: array<string, array{ // Default: []
|
||||
* adapters?: list<scalar|null>,
|
||||
* tags?: scalar|null, // Default: null
|
||||
* public?: bool, // Default: false
|
||||
* default_lifetime?: scalar|null, // Default lifetime of the pool.
|
||||
* provider?: scalar|null, // Overwrite the setting from the default provider for this adapter.
|
||||
* early_expiration_message_bus?: scalar|null,
|
||||
* clearer?: scalar|null,
|
||||
* }>,
|
||||
* },
|
||||
* php_errors?: array{ // PHP errors handling configuration
|
||||
* log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true
|
||||
* throw?: bool, // Throw PHP errors as \ErrorException instances. // Default: true
|
||||
* },
|
||||
* exceptions?: array<string, array{ // Default: []
|
||||
* log_level?: scalar|null, // The level of log message. Null to let Symfony decide. // Default: null
|
||||
* status_code?: scalar|null, // The status code of the response. Null or 0 to let Symfony decide. // Default: null
|
||||
* log_channel?: scalar|null, // The channel of log message. Null to let Symfony decide. // Default: null
|
||||
* }>,
|
||||
* web_link?: bool|array{ // Web links configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* lock?: bool|string|array{ // Lock configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* resources?: array<string, string|list<scalar|null>>,
|
||||
* },
|
||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* resources?: array<string, scalar|null>,
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* senders?: list<scalar|null>,
|
||||
* }>,
|
||||
* serializer?: array{
|
||||
* default_serializer?: scalar|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer"
|
||||
* symfony_serializer?: array{
|
||||
* format?: scalar|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json"
|
||||
* context?: array<string, mixed>,
|
||||
* },
|
||||
* },
|
||||
* transports?: array<string, string|array{ // Default: []
|
||||
* dsn?: scalar|null,
|
||||
* serializer?: scalar|null, // Service id of a custom serializer to use. // Default: null
|
||||
* options?: list<mixed>,
|
||||
* failure_transport?: scalar|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* retry_strategy?: string|array{
|
||||
* service?: scalar|null, // Service id to override the retry strategy entirely. // Default: null
|
||||
* max_retries?: int, // Default: 3
|
||||
* delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2
|
||||
* max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1
|
||||
* },
|
||||
* rate_limiter?: scalar|null, // Rate limiter name to use when processing messages. // Default: null
|
||||
* }>,
|
||||
* failure_transport?: scalar|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* stop_worker_on_signals?: list<scalar|null>,
|
||||
* default_bus?: scalar|null, // Default: null
|
||||
* buses?: array<string, array{ // Default: {"messenger.bus.default":{"default_middleware":{"enabled":true,"allow_no_handlers":false,"allow_no_senders":true},"middleware":[]}}
|
||||
* default_middleware?: bool|string|array{
|
||||
* enabled?: bool, // Default: true
|
||||
* allow_no_handlers?: bool, // Default: false
|
||||
* allow_no_senders?: bool, // Default: true
|
||||
* },
|
||||
* middleware?: list<string|array{ // Default: []
|
||||
* id: scalar|null,
|
||||
* arguments?: list<mixed>,
|
||||
* }>,
|
||||
* }>,
|
||||
* },
|
||||
* scheduler?: bool|array{ // Scheduler configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* disallow_search_engine_index?: bool, // Enabled by default when debug is enabled. // Default: true
|
||||
* http_client?: bool|array{ // HTTP Client configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* max_host_connections?: int, // The maximum number of connections to a single host.
|
||||
* default_options?: array{
|
||||
* headers?: array<string, mixed>,
|
||||
* vars?: array<string, mixed>,
|
||||
* max_redirects?: int, // The maximum number of redirects to follow.
|
||||
* http_version?: scalar|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||
* resolve?: array<string, scalar|null>,
|
||||
* proxy?: scalar|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||
* no_proxy?: scalar|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||
* timeout?: float, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||
* max_duration?: float, // The maximum execution time for the request+response as a whole.
|
||||
* bindto?: scalar|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||
* verify_peer?: bool, // Indicates if the peer should be verified in a TLS context.
|
||||
* verify_host?: bool, // Indicates if the host should exist as a certificate common name.
|
||||
* cafile?: scalar|null, // A certificate authority file.
|
||||
* capath?: scalar|null, // A directory that contains multiple certificate authority files.
|
||||
* local_cert?: scalar|null, // A PEM formatted certificate file.
|
||||
* local_pk?: scalar|null, // A private key file.
|
||||
* passphrase?: scalar|null, // The passphrase used to encrypt the "local_pk" file.
|
||||
* ciphers?: scalar|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...)
|
||||
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||
* sha1?: mixed,
|
||||
* pin-sha256?: mixed,
|
||||
* md5?: mixed,
|
||||
* },
|
||||
* crypto_method?: scalar|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||
* extra?: array<string, mixed>,
|
||||
* rate_limiter?: scalar|null, // Rate limiter name to use for throttling requests. // Default: null
|
||||
* caching?: bool|array{ // Caching configuration.
|
||||
* enabled?: bool, // Default: false
|
||||
* cache_pool?: string, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool, // Default: false
|
||||
* retry_strategy?: scalar|null, // service id to override the retry strategy. // Default: null
|
||||
* http_codes?: array<string, array{ // Default: []
|
||||
* code?: int,
|
||||
* methods?: list<string>,
|
||||
* }>,
|
||||
* max_retries?: int, // Default: 3
|
||||
* delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||
* max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||
* },
|
||||
* },
|
||||
* mock_response_factory?: scalar|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable.
|
||||
* scoped_clients?: array<string, string|array{ // Default: []
|
||||
* scope?: scalar|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.
|
||||
* base_uri?: scalar|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.
|
||||
* auth_basic?: scalar|null, // An HTTP Basic authentication "username:password".
|
||||
* auth_bearer?: scalar|null, // A token enabling HTTP Bearer authorization.
|
||||
* auth_ntlm?: scalar|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension).
|
||||
* query?: array<string, scalar|null>,
|
||||
* headers?: array<string, mixed>,
|
||||
* max_redirects?: int, // The maximum number of redirects to follow.
|
||||
* http_version?: scalar|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||
* resolve?: array<string, scalar|null>,
|
||||
* proxy?: scalar|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||
* no_proxy?: scalar|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||
* timeout?: float, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||
* max_duration?: float, // The maximum execution time for the request+response as a whole.
|
||||
* bindto?: scalar|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||
* verify_peer?: bool, // Indicates if the peer should be verified in a TLS context.
|
||||
* verify_host?: bool, // Indicates if the host should exist as a certificate common name.
|
||||
* cafile?: scalar|null, // A certificate authority file.
|
||||
* capath?: scalar|null, // A directory that contains multiple certificate authority files.
|
||||
* local_cert?: scalar|null, // A PEM formatted certificate file.
|
||||
* local_pk?: scalar|null, // A private key file.
|
||||
* passphrase?: scalar|null, // The passphrase used to encrypt the "local_pk" file.
|
||||
* ciphers?: scalar|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...).
|
||||
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||
* sha1?: mixed,
|
||||
* pin-sha256?: mixed,
|
||||
* md5?: mixed,
|
||||
* },
|
||||
* crypto_method?: scalar|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||
* extra?: array<string, mixed>,
|
||||
* rate_limiter?: scalar|null, // Rate limiter name to use for throttling requests. // Default: null
|
||||
* caching?: bool|array{ // Caching configuration.
|
||||
* enabled?: bool, // Default: false
|
||||
* cache_pool?: string, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool, // Default: false
|
||||
* retry_strategy?: scalar|null, // service id to override the retry strategy. // Default: null
|
||||
* http_codes?: array<string, array{ // Default: []
|
||||
* code?: int,
|
||||
* methods?: list<string>,
|
||||
* }>,
|
||||
* max_retries?: int, // Default: 3
|
||||
* delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||
* max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||
* },
|
||||
* }>,
|
||||
* },
|
||||
* mailer?: bool|array{ // Mailer configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* message_bus?: scalar|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||
* dsn?: scalar|null, // Default: null
|
||||
* transports?: array<string, scalar|null>,
|
||||
* envelope?: array{ // Mailer Envelope configuration
|
||||
* sender?: scalar|null,
|
||||
* recipients?: list<scalar|null>,
|
||||
* allowed_recipients?: list<scalar|null>,
|
||||
* },
|
||||
* headers?: array<string, string|array{ // Default: []
|
||||
* value?: mixed,
|
||||
* }>,
|
||||
* dkim_signer?: bool|array{ // DKIM signer configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* key?: scalar|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: ""
|
||||
* domain?: scalar|null, // Default: ""
|
||||
* select?: scalar|null, // Default: ""
|
||||
* passphrase?: scalar|null, // The private key passphrase // Default: ""
|
||||
* options?: array<string, mixed>,
|
||||
* },
|
||||
* smime_signer?: bool|array{ // S/MIME signer configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* key?: scalar|null, // Path to key (in PEM format) // Default: ""
|
||||
* certificate?: scalar|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: ""
|
||||
* passphrase?: scalar|null, // The private key passphrase // Default: null
|
||||
* extra_certificates?: scalar|null, // Default: null
|
||||
* sign_options?: int, // Default: null
|
||||
* },
|
||||
* smime_encrypter?: bool|array{ // S/MIME encrypter configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* repository?: scalar|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: ""
|
||||
* cipher?: int, // A set of algorithms used to encrypt the message // Default: null
|
||||
* },
|
||||
* },
|
||||
* secrets?: bool|array{
|
||||
* enabled?: bool, // Default: true
|
||||
* vault_directory?: scalar|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%"
|
||||
* local_dotenv_file?: scalar|null, // Default: "%kernel.project_dir%/.env.%kernel.runtime_environment%.local"
|
||||
* decryption_env_var?: scalar|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET"
|
||||
* },
|
||||
* notifier?: bool|array{ // Notifier configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* message_bus?: scalar|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||
* chatter_transports?: array<string, scalar|null>,
|
||||
* texter_transports?: array<string, scalar|null>,
|
||||
* notification_on_failed_messages?: bool, // Default: false
|
||||
* channel_policy?: array<string, string|list<scalar|null>>,
|
||||
* admin_recipients?: list<array{ // Default: []
|
||||
* email?: scalar|null,
|
||||
* phone?: scalar|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* limiters?: array<string, array{ // Default: []
|
||||
* lock_factory?: scalar|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
* storage_service?: scalar|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit", // The algorithm to be used by this limiter.
|
||||
* limiters?: list<scalar|null>,
|
||||
* limit?: int, // The maximum allowed hits in a fixed interval or burst.
|
||||
* interval?: scalar|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
* rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket".
|
||||
* interval?: scalar|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
* amount?: int, // Amount of tokens to add each interval. // Default: 1
|
||||
* },
|
||||
* }>,
|
||||
* },
|
||||
* uid?: bool|array{ // Uid configuration
|
||||
* enabled?: bool, // Default: true
|
||||
* default_uuid_version?: 7|6|4|1, // Default: 7
|
||||
* name_based_uuid_version?: 5|3, // Default: 5
|
||||
* name_based_uuid_namespace?: scalar|null,
|
||||
* time_based_uuid_version?: 7|6|1, // Default: 7
|
||||
* time_based_uuid_node?: scalar|null,
|
||||
* },
|
||||
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* sanitizers?: array<string, array{ // Default: []
|
||||
* allow_safe_elements?: bool, // Allows "safe" elements and attributes. // Default: false
|
||||
* allow_static_elements?: bool, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
|
||||
* allow_elements?: array<string, mixed>,
|
||||
* block_elements?: list<string>,
|
||||
* drop_elements?: list<string>,
|
||||
* allow_attributes?: array<string, mixed>,
|
||||
* drop_attributes?: array<string, mixed>,
|
||||
* force_attributes?: array<string, array<string, string>>,
|
||||
* force_https_urls?: bool, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false
|
||||
* allowed_link_schemes?: list<string>,
|
||||
* allowed_link_hosts?: list<string>|null,
|
||||
* allow_relative_links?: bool, // Allows relative URLs to be used in links href attributes. // Default: false
|
||||
* allowed_media_schemes?: list<string>,
|
||||
* allowed_media_hosts?: list<string>|null,
|
||||
* allow_relative_medias?: bool, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false
|
||||
* with_attribute_sanitizers?: list<string>,
|
||||
* without_attribute_sanitizers?: list<string>,
|
||||
* max_input_length?: int, // The maximum length allowed for the sanitized input. // Default: 0
|
||||
* }>,
|
||||
* },
|
||||
* webhook?: bool|array{ // Webhook configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* message_bus?: scalar|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service: scalar|null,
|
||||
* secret?: scalar|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
* remote-event?: bool|array{ // RemoteEvent configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* json_streamer?: bool|array{ // JSON streamer configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* },
|
||||
* }
|
||||
* @psalm-type TwigConfig = array{
|
||||
* form_themes?: list<scalar|null>,
|
||||
* globals?: array<string, array{ // Default: []
|
||||
* id?: scalar|null,
|
||||
* type?: scalar|null,
|
||||
* value?: mixed,
|
||||
* }>,
|
||||
* autoescape_service?: scalar|null, // Default: null
|
||||
* autoescape_service_method?: scalar|null, // Default: null
|
||||
* base_template_class?: scalar|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated.
|
||||
* cache?: scalar|null, // Default: true
|
||||
* charset?: scalar|null, // Default: "%kernel.charset%"
|
||||
* debug?: bool, // Default: "%kernel.debug%"
|
||||
* strict_variables?: bool, // Default: "%kernel.debug%"
|
||||
* auto_reload?: scalar|null,
|
||||
* optimizations?: int,
|
||||
* default_path?: scalar|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
|
||||
* file_name_pattern?: list<scalar|null>,
|
||||
* paths?: array<string, mixed>,
|
||||
* date?: array{ // The default format options used by the date filter.
|
||||
* format?: scalar|null, // Default: "F j, Y H:i"
|
||||
* interval_format?: scalar|null, // Default: "%d days"
|
||||
* timezone?: scalar|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
|
||||
* },
|
||||
* number_format?: array{ // The default format options for the number_format filter.
|
||||
* decimals?: int, // Default: 0
|
||||
* decimal_point?: scalar|null, // Default: "."
|
||||
* thousands_separator?: scalar|null, // Default: ","
|
||||
* },
|
||||
* mailer?: array{
|
||||
* html_to_text_converter?: scalar|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
|
||||
* },
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* ...<string, ExtensionType>,
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
final class App
|
||||
{
|
||||
/**
|
||||
* @param ConfigType $config
|
||||
*
|
||||
* @psalm-return ConfigType
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
return AppReference::config($config);
|
||||
}
|
||||
}
|
||||
|
||||
namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
|
||||
/**
|
||||
* This class provides array-shapes for configuring the routes of an application.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```php
|
||||
* // config/routes.php
|
||||
* namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
*
|
||||
* return Routes::config([
|
||||
* 'controllers' => [
|
||||
* 'resource' => 'routing.controllers',
|
||||
* ],
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @psalm-type RouteConfig = array{
|
||||
* path: string|array<string,string>,
|
||||
* controller?: string,
|
||||
* methods?: string|list<string>,
|
||||
* requirements?: array<string,string>,
|
||||
* defaults?: array<string,mixed>,
|
||||
* options?: array<string,mixed>,
|
||||
* host?: string|array<string,string>,
|
||||
* schemes?: string|list<string>,
|
||||
* condition?: string,
|
||||
* locale?: string,
|
||||
* format?: string,
|
||||
* utf8?: bool,
|
||||
* stateless?: bool,
|
||||
* }
|
||||
* @psalm-type ImportConfig = array{
|
||||
* resource: string,
|
||||
* type?: string,
|
||||
* exclude?: string|list<string>,
|
||||
* prefix?: string|array<string,string>,
|
||||
* name_prefix?: string,
|
||||
* trailing_slash_on_root?: bool,
|
||||
* controller?: string,
|
||||
* methods?: string|list<string>,
|
||||
* requirements?: array<string,string>,
|
||||
* defaults?: array<string,mixed>,
|
||||
* options?: array<string,mixed>,
|
||||
* host?: string|array<string,string>,
|
||||
* schemes?: string|list<string>,
|
||||
* condition?: string,
|
||||
* locale?: string,
|
||||
* format?: string,
|
||||
* utf8?: bool,
|
||||
* stateless?: bool,
|
||||
* }
|
||||
* @psalm-type AliasConfig = array{
|
||||
* alias: string,
|
||||
* deprecated?: array{package:string, version:string, message?:string},
|
||||
* }
|
||||
* @psalm-type RoutesConfig = array{
|
||||
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||
* }
|
||||
*/
|
||||
final class Routes
|
||||
{
|
||||
/**
|
||||
* @param RoutesConfig $config
|
||||
*
|
||||
* @psalm-return RoutesConfig
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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
|
||||
@@ -0,0 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||
prefix: /_error
|
||||
@@ -0,0 +1,83 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
# See also https://symfony.com/doc/current/service_container/import.html
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html
|
||||
# #use-parameters-for-application-configuration
|
||||
parameters:
|
||||
# --- main variables ---
|
||||
# URI containing secret and config for TOTP, which determines the token to login
|
||||
# app will generate one, if not provided, but you should copy it to your .env file
|
||||
# format: "otpauth://totp/<label>?secret=<secret-key>"
|
||||
env(TOTP_URI): '' # blank to have the app generate one at random
|
||||
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
|
||||
env(COOKIE_TTL): '2592000' # default 30 days
|
||||
# rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
||||
# number of consecutive failed login attempts before we block the ip address
|
||||
env(LIMIT): '4' # default 4 failed login attempts before blocking
|
||||
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
|
||||
env(LIMIT_TIMEOUT): '21600' # default 6 hours
|
||||
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
|
||||
env(LIMIT_TTL): '86400' # default 24 hours
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- styling variables ---
|
||||
env(TITLE): 'Pre-Authentication System'
|
||||
env(BG_COLOR): '#029386'
|
||||
env(FG_COLOR): '#ffffff'
|
||||
env(ERROR_COLOR): '#ffb16d'
|
||||
env(ID_NAME): 'Session ID'
|
||||
env(TOKEN_NAME): 'Authentication Token'
|
||||
env(SUBMIT_NAME): 'Submit'
|
||||
env(ERROR_MESSAGE): 'Unsuccessful login attempt'
|
||||
# title and message to use on block page, if teapot is true
|
||||
env(TEAPOT_TITLE): "I'm a teapot"
|
||||
env(TEAPOT_MESSAGE): 'I refuse to brew coffee'
|
||||
# title and message to use on block page, if teapot is false
|
||||
env(TOO_MANY_TITLE): 'Too many requests'
|
||||
env(TOO_MANY_MESSAGE): 'Try again later'
|
||||
|
||||
app.cookie_ttl: '%env(COOKIE_TTL)%'
|
||||
app.limit: '%env(LIMIT)%'
|
||||
app.limit_timeout: '%env(LIMIT_TIMEOUT)%'
|
||||
app.limit_ttl: '%env(LIMIT_TTL)%'
|
||||
app.query_prefix: '%env(QUERY_PREFIX)%'
|
||||
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)%'
|
||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services.
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
@@ -0,0 +1,35 @@
|
||||
# if using caddy v2.9.x+ you can use this snippet
|
||||
# snippet to put the pre-auth system in front any service easily
|
||||
(preauth) {
|
||||
# make sure caddy and preauth are on the same network
|
||||
reverse_proxy {args[0]} preauth {
|
||||
method GET
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
@preauth_ok status 2xx
|
||||
handle_response @preauth_ok {
|
||||
copy_response_headers {
|
||||
include Set-Cookie Location
|
||||
}
|
||||
{block}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# example of securing full subdomain
|
||||
# TODO replace domain and service name
|
||||
service.example.com {
|
||||
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/
|
||||
protected.example.com {
|
||||
import preauth /secure/* {
|
||||
reverse_proxy protected-service:9000
|
||||
}
|
||||
reverse_proxy exposed-service:9000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# TODO rename ".env.example" to just ".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
|
||||
expose:
|
||||
- 80
|
||||
image: digitaladapt/preauth:latest
|
||||
restart: unless-stopped
|
||||
# if you wish to set the user, you must make sure that the user
|
||||
# can write to /app/var/ within the container, and that all files
|
||||
# and folders within are writable as well
|
||||
# IE: `$chown -R <uid>:<gid> /path/to/volume/of/app/var`
|
||||
#
|
||||
#user: <uid>:<gid>
|
||||
volumes:
|
||||
- preauth:/app/var
|
||||
|
||||
volumes:
|
||||
preauth:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# --- Main Options ---
|
||||
|
||||
# URI containing secret and config for TOTP, which determines the token to login
|
||||
# app will generate one, if not provided, but you should copy it to your .env file
|
||||
# format: "otpauth://totp/<label>?secret=<secret-key>"
|
||||
#TOTP_URI='' # blank to have the app generate one at random
|
||||
|
||||
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
|
||||
#COOKIE_TTL=2592000 # default 30 days
|
||||
|
||||
# NOTE: rate limiting can *NOT* be disabled,
|
||||
# but you could allow hundreds of logins a second
|
||||
|
||||
# number of consecutive failed login attempts before we block the ip address
|
||||
#LIMIT=4 # default 4 failed login attempts before blocking
|
||||
|
||||
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
|
||||
#LIMIT_TIMEOUT=21600 # default 6 hours
|
||||
|
||||
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
|
||||
#LIMIT_TTL=86400 # default 24 hours
|
||||
|
||||
# --- Extra Options ---
|
||||
|
||||
# query parameter prefix to prevent collisions with protected app
|
||||
#QUERY_PREFIX='_preauth_'
|
||||
|
||||
# TODO make it so boolean options can be true/false
|
||||
|
||||
# allow files in /app/public/assets directory to be served, false to disable
|
||||
#ASSETS=true # default enabled, boolean
|
||||
|
||||
# how long do we allow *ALL* traffic from an ip address after successful login
|
||||
# could be useful if you have a system which does not handle cookies
|
||||
#IP_TTL=0 # default disabled, time in seconds
|
||||
|
||||
# if desired, in addition to supporting a TOTP, you can set a static password
|
||||
#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
|
||||
|
||||
# --- 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)
|
||||
#ID_NAME='Session ID'
|
||||
#TOKEN_NAME='Authentication Token'
|
||||
#SUBMIT_NAME='Submit'
|
||||
#ERROR_MESSAGE='Unsuccessful login attempt'
|
||||
# title and message to use on block page, if teapot is true
|
||||
#TEAPOT_TITLE="I'm a teapot"
|
||||
#TEAPOT_MESSAGE='I refuse to brew coffee'
|
||||
# title and message to use on block page, if teapot is false
|
||||
#TOO_MANY_TITLE='Too many requests'
|
||||
#TOO_MANY_MESSAGE='Try again later'
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
/* do garbage collection on rate limit monitoring sessions */
|
||||
session_start();
|
||||
$success = session_gc();
|
||||
session_destroy();
|
||||
|
||||
if ($success !== false) {
|
||||
/* if garbage collection was successful, report that we are healthy */
|
||||
echo implode('', ['o', 'n', 'l', 'i', 'n', 'e', "\n"]);
|
||||
} else {
|
||||
echo "error\n";
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$key = getenv('PREAUTH_KEY');
|
||||
|
||||
// generate a new key, if not specified
|
||||
if ( ! $key) {
|
||||
$key = base64_encode(random_bytes(64));
|
||||
error_log("ERROR: PREAUTH_KEY is not set, generating a random session encryption key:\n'$key'\nupdate your config or it will be regenerated when you restart.");
|
||||
}
|
||||
|
||||
echo $key;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use OTPHP\TOTP;
|
||||
|
||||
$token = getenv('PREAUTH_TOKEN');
|
||||
|
||||
// generate a new token, if not specified
|
||||
if ( ! $token) {
|
||||
$token = TOTP::generate()->getSecret();
|
||||
error_log("ERROR: PREAUTH_TOKEN is not set, generating random TOTP token:\n'$token'\nupdate your config or it will be regenerated when you restart.");
|
||||
}
|
||||
|
||||
echo $token;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ensure we have required folders
|
||||
`mkdir -p /tmp/data/sessions`
|
||||
`mkdir -p /tmp/data/monitor`
|
||||
|
||||
# ensure we have required settings
|
||||
if [ -z "$PREAUTH_KEY" ]; then
|
||||
export PREAUTH_KEY=$(cd /preauth && php init-key.php)
|
||||
fi
|
||||
|
||||
if [ -z "$PREAUTH_TOKEN" ]; then
|
||||
export PREAUTH_TOKEN=$(cd /preauth && php init-token.php)
|
||||
fi
|
||||
|
||||
# start the process, now that setup is done
|
||||
exec php-fpm
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
; preauth uses php sessions to store usage statistics by IP address and is used for rate limiting
|
||||
; login sessions are stored as file in /tmp/data/sessions, and are unrelated to this configuration
|
||||
|
||||
; sessions are IP based, cookie not needed nor wanted
|
||||
session.use_cookies = off
|
||||
; deprecated in php 8.4
|
||||
;session.use_only_cookies = off
|
||||
session.cache_limiter = ''
|
||||
session.name = preauth
|
||||
session.save_path = /tmp/data/monitor
|
||||
|
||||
; seconds until a session may be pruned
|
||||
; IE: maximum time range to review for rate limiting and
|
||||
; maximum time an IP address can be blocked
|
||||
; 86400 seconds aka 24 hours hardcoded upper limit
|
||||
session.gc_maxlifetime = 86400
|
||||
|
||||
; disable random random garbage collection
|
||||
; our healthcheck runs session_gc function
|
||||
session.gc_probability = 0
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Preauth\Auth;
|
||||
|
||||
/* kickoff the script */
|
||||
$auth = new Auth($_SERVER['HTTP_HOST'] ?? 'example.com');
|
||||
$auth->run();
|
||||
|
||||
/* no additional output most of the time, sometimes we'll continue to display the login screen */
|
||||
if ($auth->showTemplate()) {
|
||||
include __DIR__ . '/template.php';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
@@ -14,14 +14,21 @@ Maybe you need the extra protection because it's a very sensitive system, or bec
|
||||
|
||||
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 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 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.
|
||||
|
||||
-356
@@ -1,356 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Preauth;
|
||||
|
||||
use OTPHP\TOTP;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class Auth {
|
||||
/* name of the return-to field */
|
||||
public const RETURN_FIELD = 'preauth_rt';
|
||||
/* name of the id field */
|
||||
public const ID_FIELD = 'preauth_id';
|
||||
/* name of the token field */
|
||||
public const TOKEN_FIELD = 'preauth_token';
|
||||
/* the encryption cipher we are using */
|
||||
private const CIPHER = 'camellia-256-ctr';
|
||||
/* only allow A-z 0-9 _ - */
|
||||
private const URL64 = '/[^A-Za-z0-9_-]+/';
|
||||
/* cookie name */
|
||||
private const NAME = '_auth_uuid';
|
||||
/* directory to store sessions in */
|
||||
private const BASE = '/tmp/data/sessions/';
|
||||
/* top-level-domains which are known to have multiple parts */
|
||||
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'],
|
||||
];
|
||||
|
||||
/** @var string $preauth subdomain (without domain) the login page will use */
|
||||
private string $preauth;
|
||||
/** @var string $domain domain (without subdomains) we are controlling auth for */
|
||||
private string $domain;
|
||||
/** @var int $expire time-to-live of auth cookie */
|
||||
private int $expire;
|
||||
/** @var array $get like $_GET, but based on data given by reverse proxy */
|
||||
private array $get = [];
|
||||
/** @var string $key secret for encryption to store sessions */
|
||||
private string $key;
|
||||
/** @var string $token secret for 2FA token */
|
||||
private string $token;
|
||||
/** @var bool $stop set to false to print login page */
|
||||
private bool $stop = true;
|
||||
/** @var string $id user provided name for their session */
|
||||
private string $id;
|
||||
|
||||
/**
|
||||
* @param string $host domain which auth is relative to
|
||||
*/
|
||||
public function __construct(string $host) {
|
||||
$this->preauth = getenv('PREAUTH_SUBDOMAIN') ?: 'preauth';
|
||||
$this->domain = $this->baseDomain($host);
|
||||
$this->key = base64_decode(getenv('PREAUTH_KEY') ?: '');
|
||||
$this->token = getenv('PREAUTH_TOKEN') ?: '';
|
||||
$this->expire = time() + 60 * ((int)getenv('PREAUTH_TTL') ?: 43200);
|
||||
parse_str((parse_url((
|
||||
$_SERVER['HTTP_X_FORWARDED_URI'] ?? ''
|
||||
), PHP_URL_QUERY) ?: ''), $this->get);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean returns true if we should display login page, false if we are done
|
||||
*/
|
||||
public function showTemplate(): bool {
|
||||
return ( ! $this->stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the return-to-url, if one was specified, empty string otherwise
|
||||
*/
|
||||
public function getReturnTo(): string {
|
||||
$rt = $this->get[self::RETURN_FIELD] ?? '';
|
||||
if ( ! is_string($rt)) {
|
||||
$rt = '';
|
||||
}
|
||||
return $rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns our current domain with all subdomains removed
|
||||
*/
|
||||
public function getBaseDomain(): string {
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point of code, review request and determine course of action
|
||||
*/
|
||||
public function run(): void {
|
||||
if ( ! $this->key || ! $this->token) {
|
||||
$this->die();
|
||||
}
|
||||
|
||||
/* already logged in with valid session, return 200, so caddy permits request */
|
||||
$uuid = $this->getExistingUUID();
|
||||
if ($uuid) {
|
||||
echo "ok $this->id";
|
||||
return;
|
||||
}
|
||||
|
||||
/* if too many requests (from the remote-ip), then trigger rate limiting */
|
||||
if ($this->rateLimit()) {
|
||||
include __DIR__ . '/../400.php';
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/* if request is valid login attempt, (set cookie and) return to where they came from */
|
||||
if ($this->login()) {
|
||||
if ($this->getReturnTo()) {
|
||||
header("Location: {$this->getReturnTo()}");
|
||||
} else if (getenv('PREAUTH_SEND_TO')) {
|
||||
header("Location: " . getenv('PREAUTH_SEND_TO'));
|
||||
}
|
||||
echo "ok $this->id";
|
||||
return;
|
||||
}
|
||||
|
||||
/* not already logged in, but on auth page, so present login screen */
|
||||
/* either not trying to login or had a failed login attempt */
|
||||
if ($_SERVER['HTTP_HOST'] === "$this->preauth.$this->domain") {
|
||||
header('http/1.1 401 Unauthorized', true, 401);
|
||||
$this->stop = false;
|
||||
} else {
|
||||
/* not already logged in, not trying to login, and not on auth page */
|
||||
/* so send to login screen */
|
||||
$query = self::RETURN_FIELD . '=' . rawurlencode(
|
||||
($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https') . '://' .
|
||||
($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $this->domain) .
|
||||
($_SERVER['HTTP_X_FORWARDED_URI'] ?? '/')
|
||||
);
|
||||
header("Location: https://$this->preauth.$this->domain/?$query");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This lets us determine the base domain:
|
||||
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
||||
* @param string $host domain with zero or more subdomains
|
||||
* @return string returns same domain with all subdomains removed
|
||||
*/
|
||||
private function baseDomain(string $host): string {
|
||||
$parts = array_reverse(explode('.', $host));
|
||||
$keep = min(2, count($parts));
|
||||
/* check if host should retain 3 parts, due to TLD */
|
||||
if (count($parts) > 2 && isset(self::TLD[$parts[0]]) &&
|
||||
in_array($parts[1], self::TLD[$parts[0]], true)
|
||||
) {
|
||||
$keep = 3;
|
||||
}
|
||||
$parts = array_reverse(array_slice($parts, 0, $keep));
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop, critical server config issue
|
||||
*/
|
||||
private function die(): string {
|
||||
error_log('PREAUTH_KEY and/or PREAUTH_TOKEN are not set, unable to continue.');
|
||||
header('http/1.1 500 Internal Server Error', true, 500);
|
||||
include __DIR__ . '/../500.php';
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null returns the existing UUID if there is valid one, null otherwise
|
||||
*/
|
||||
private function getExistingUUID(): ?string {
|
||||
/* filter user input */
|
||||
$encUUID = preg_replace(self::URL64, '', ($_COOKIE[self::NAME] ?? ''));
|
||||
|
||||
/* session exists as a file where the filename is the encoded-uuid */
|
||||
/* in the format '<iv-b64>$<session-name>$<expiration>$<date>$<remote-host>' */
|
||||
if ($encUUID && is_file(self::BASE . $encUUID)) {
|
||||
[$iv, $id, $expire] = explode('$', (file_get_contents(
|
||||
self::BASE . $encUUID
|
||||
) ?: '') . '$$');
|
||||
if ($iv) {
|
||||
/* raw-uuid means not encoded, but still encrypted */
|
||||
$rawUUID = base64_decode(strtr($encUUID, '+/', '-_'));
|
||||
$rawIV = base64_decode($iv);
|
||||
$uuid = openssl_decrypt($rawUUID, self::CIPHER, $this->key, 0, $rawIV);
|
||||
if (Uuid::isValid($uuid) && (int)$expire >= time()) {
|
||||
$this->id = substr(preg_replace(self::URL64, '', $id), 0, 100);
|
||||
return $uuid;
|
||||
}
|
||||
/* we have something encrypted, but it is expired or invalid, so delete it */
|
||||
unlink(self::BASE . $encUUID);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the remote-host has made too many login requests recently, and block if needed
|
||||
* @return bool returns true if we should rate-limit this request, false otherwise
|
||||
*/
|
||||
private function rateLimit(): bool {
|
||||
/* our identifier for this remote-host, replace all special characters with dashes */
|
||||
/* session_id() only allows ",", "-", and alphanumeric characters */
|
||||
$limiterId = preg_replace('/[^a-zA-Z0-9]/', '-', $this->getRemoteHost());
|
||||
//error_log("limiterId: {$limiterId}");
|
||||
session_id($limiterId);
|
||||
session_start();
|
||||
|
||||
/* new remote-ip, start logging */
|
||||
if ( ! isset($_SESSION['count'], $_SESSION['time'])) {
|
||||
$this->resetRateLimit(false);
|
||||
//error_log('limiter new, allow');
|
||||
return false;
|
||||
}
|
||||
|
||||
$sessionCount = (int)$_SESSION['count'];
|
||||
$sessionTime = (int)$_SESSION['time'];
|
||||
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
|
||||
$rateTimeout = (int)getenv('PREAUTH_RATE_TIMEOUT') ?: 360;
|
||||
$rateBlocked = (int)getenv('PREAUTH_RATE_BLOCKED') ?: 1440;
|
||||
$rateMaximum = max($rateTimeout, $rateBlocked);
|
||||
|
||||
if ($sessionCount >= $rateLimit && time() - $rateBlocked <= $sessionTime) {
|
||||
/* if over limit, and block current, block them */
|
||||
//error_log("limiter over limit, block ($sessionCount)");
|
||||
return true;
|
||||
} else if ($sessionCount < $rateLimit && time() - $rateTimeout <= $sessionTime) {
|
||||
/* if under limit, and timeout current, allow them */
|
||||
//error_log("limiter under limit, allow ($sessionCount)");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* either over limit and block has expired or */
|
||||
/* under limit and timeout has expired, so reset them */
|
||||
//error_log("limiter expired, reset");
|
||||
$this->resetRateLimit(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns a newly generated random uuid
|
||||
*/
|
||||
private function newUUID(): string {
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upon valid token and non-empty id, creates a uuid, stores session file and sets the cookie
|
||||
* @return bool returns true if token and id are provided, and token is valid, false otherwise
|
||||
*/
|
||||
private function login(): bool {
|
||||
/* fields must both be filled out */
|
||||
if (isset($this->get[self::TOKEN_FIELD], $this->get[self::ID_FIELD]) &&
|
||||
$this->get[self::TOKEN_FIELD] && $this->get[self::ID_FIELD]
|
||||
) {
|
||||
/* filter user input */
|
||||
$id = substr(preg_replace(self::URL64, '', $this->get[self::ID_FIELD]), 0, 100);
|
||||
$otp = TOTP::createFromSecret($this->token);
|
||||
$date = date('Y-m-d H:i:s');
|
||||
$remoteHost = $this->getRemoteHost();
|
||||
|
||||
/* if given token is valid, login, store session file and set the cookie */
|
||||
if ($otp->now() === $this->get[self::TOKEN_FIELD]) {
|
||||
$this->id = $id;
|
||||
$uuid = $this->newUUID();
|
||||
$rawIV = random_bytes(openssl_cipher_iv_length(self::CIPHER));
|
||||
$rawUUID = openssl_encrypt($uuid, self::CIPHER, $this->key, 0, $rawIV);
|
||||
$encUUID = strtr(base64_encode($rawUUID), '-_', '+/');
|
||||
file_put_contents(
|
||||
self::BASE . $encUUID,
|
||||
base64_encode($rawIV) .
|
||||
"\$$id\$$this->expire\$$date\$$remoteHost\$\n"
|
||||
);
|
||||
setcookie(
|
||||
self::NAME,
|
||||
$encUUID,
|
||||
$this->expire,
|
||||
'/', /* all paths */
|
||||
$this->domain, /* all subdomains */
|
||||
true, /* https only */
|
||||
true /* no js access */
|
||||
);
|
||||
error_log("[$date] successful login by id: $id");
|
||||
/* successful login, reset the rate-limit and close */
|
||||
$this->resetRateLimit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* login attempted and failed, update monitoring for rate limiting */
|
||||
$sessionCount = (int)$_SESSION['count'];
|
||||
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
|
||||
$this->logFailedAttempt();
|
||||
|
||||
if (($sessionCount + 1) >= $rateLimit) {
|
||||
/* +1 to count this failure */
|
||||
error_log("[$date] rate-limiting trigger for: $remoteHost");
|
||||
/* just reached the rate limit, block them */
|
||||
include __DIR__ . '/../400.php';
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine real remote-host, if local address, find next level up
|
||||
* @return string returns the real remote host (IP address)
|
||||
*/
|
||||
private function getRemoteHost(): string {
|
||||
$remoteHost = $_SERVER['REMOTE_HOST'];
|
||||
if (IpUtils::isPrivateIp($remoteHost)) {
|
||||
$remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
|
||||
$remoteIndex = array_search($remoteHost, $remoteList);
|
||||
if ($remoteIndex > 0) {
|
||||
$remoteHost = $remoteList[$remoteIndex - 1];
|
||||
}
|
||||
}
|
||||
return $remoteHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* [Re]Initialize the rate-limiting and close monitoring session (unless you pass false)
|
||||
* @param bool $close Defaults to true, set to false to keep monitoring session open
|
||||
*/
|
||||
private function resetRateLimit(bool $close = true): void {
|
||||
$_SESSION['time'] = time();
|
||||
$_SESSION['count'] = 0;
|
||||
if ($close) {
|
||||
session_write_close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that login failed so we can determine if we should rate-limit future requests
|
||||
*/
|
||||
private function logFailedAttempt(): void {
|
||||
$_SESSION['time'] = time();
|
||||
$_SESSION['count']++;
|
||||
session_write_close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
|
||||
|
||||
#[AsAlias(ClockInterface::class)]
|
||||
final readonly class Clock implements ClockInterface {
|
||||
public function now(): DateTimeImmutable {
|
||||
return new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
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;
|
||||
private string $tooManyTitle;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
Utilities $utilities,
|
||||
ClockInterface $clock,
|
||||
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
|
||||
#[Autowire('%app.limit%')] int $limit,
|
||||
#[Autowire('%app.limit_timeout%')] int $limitTimeout,
|
||||
#[Autowire('%app.limit_ttl%')] int $limitTtl,
|
||||
#[Autowire('%app.query_prefix%')] string $queryPrefix,
|
||||
#[Autowire('%app.totp_uri%')] string $totpUri,
|
||||
#[Autowire('%app.assets%')] bool $assets,
|
||||
#[Autowire('%kernel.project_dir%/public/assets/')] string $assetsDir,
|
||||
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
||||
#[Autowire('%app.static_secret%')] ?string $staticSecret,
|
||||
#[Autowire('%app.teapot%')] bool $teapot,
|
||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
||||
) {
|
||||
$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;
|
||||
$this->tooManyTitle = $tooManyTitle;
|
||||
}
|
||||
|
||||
public function clock(): ClockInterface {
|
||||
return $this->clock;
|
||||
}
|
||||
|
||||
public function cookieTtl(): int {
|
||||
return $this->cookieTtl;
|
||||
}
|
||||
|
||||
public function limit(): int {
|
||||
return $this->limit;
|
||||
}
|
||||
|
||||
public function limitTimeout(): int {
|
||||
return $this->limitTimeout;
|
||||
}
|
||||
|
||||
public function limitTtl(): int {
|
||||
return $this->limitTtl;
|
||||
}
|
||||
|
||||
public function query(string $field): string {
|
||||
return "$this->queryPrefix$field";
|
||||
}
|
||||
|
||||
public function totpUri(): string {
|
||||
return $this->totpUri;
|
||||
}
|
||||
|
||||
public function assetsDir(): ?string {
|
||||
return $this->assetsDir;
|
||||
}
|
||||
|
||||
public function ipTtl(): ?int {
|
||||
return $this->ipTtl;
|
||||
}
|
||||
|
||||
public function staticSecret(): ?string {
|
||||
return $this->staticSecret;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Preauth;
|
||||
|
||||
class Env {
|
||||
/**
|
||||
* @return string returns title of this system
|
||||
*/
|
||||
public function getTitle(): string {
|
||||
return getenv('PREAUTH_TITLE')
|
||||
?: 'Pre-Authentication System';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns background-color of this system
|
||||
*/
|
||||
public function getColor(): string {
|
||||
// defaults to teal
|
||||
return getenv('PREAUTH_BACKGROUND') ?: '#029386';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns text-color of this system
|
||||
*/
|
||||
public function getTextColor(): string {
|
||||
// defaults to white
|
||||
return getenv('PREAUTH_FOREGROUND') ?: '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the name of the ID field
|
||||
*/
|
||||
public function getIdName(): string {
|
||||
return getenv('PREAUTH_ID_NAME') ?: 'Session ID';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the name of the Token field
|
||||
*/
|
||||
public function getTokenName(): string {
|
||||
return getenv('PREAUTH_TOKEN_NAME')
|
||||
?: 'Authentication Token';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the name of the Submit button
|
||||
*/
|
||||
public function getSubmitName(): string {
|
||||
return getenv('PREAUTH_SUBMIT_NAME') ?: 'Submit';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied http status code
|
||||
*/
|
||||
public function getDeniedCode(): string {
|
||||
return getenv('PREAUTH_DENIED_CODE') ?: '418';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied response title
|
||||
*/
|
||||
public function getDeniedTitle(): string {
|
||||
return getenv('PREAUTH_DENIED_TITLE')
|
||||
?: "I'm a teapot";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied response message
|
||||
*/
|
||||
public function getDeniedMessage(): string {
|
||||
return getenv('PREAUTH_DENIED_MESSAGE')
|
||||
?: 'I refuse to brew coffee.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
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;
|
||||
|
||||
final class Kernel extends BaseKernel {
|
||||
use MicroKernelTrait;
|
||||
|
||||
private PersistCache $persistCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function boot(): void {
|
||||
parent::boot();
|
||||
|
||||
$this->persistCache = $this->container->get(PersistCache::class);
|
||||
$this->persistCache->boot();
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function terminate(Request $request, Response $response): void {
|
||||
$this->persistCache->persist();
|
||||
|
||||
parent::terminate($request, $response);
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use OutOfBoundsException;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
|
||||
/* We must not store the key-list item or values within this object,
|
||||
* because it can change from outside this object instance. */
|
||||
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
||||
private const KEY_LIST = '__key_list';
|
||||
private const IS_DIRTY = '__is_dirty';
|
||||
|
||||
private CacheItemPoolInterface $cache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(CacheItemPoolInterface $cache) {
|
||||
$this->cache = $cache;
|
||||
$items = $cache->getItems([self::KEY_LIST, self::IS_DIRTY]);
|
||||
foreach ($items as $item) {
|
||||
if ( ! $item->isHit()) {
|
||||
$this->initialize();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function initialize(): void {
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
$keyList->set([]);
|
||||
$isDirty->set(false);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->saveDeferred($isDirty);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function getKeys(): array {
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
return array_keys($keyList->get() ?? []);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function isDirty(): bool {
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
return $isDirty->get() ?? false;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function markClean(): void {
|
||||
$isDirty = $this->cache->getItem(self::IS_DIRTY);
|
||||
$isDirty->set(false);
|
||||
$this->cache->save($isDirty);
|
||||
}
|
||||
|
||||
public function getItem(string $key): CacheItemInterface {
|
||||
return $this->cache->getItem($key);
|
||||
}
|
||||
|
||||
public function getItems(array $keys = []): iterable {
|
||||
return $this->cache->getItems($keys);
|
||||
}
|
||||
|
||||
public function hasItem(string $key): bool {
|
||||
return $this->cache->hasItem($key);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function clear(): bool {
|
||||
/* only bother clearing the pool if it is not empty */
|
||||
if ( ! empty($this->getKeys())) {
|
||||
$response = $this->cache->clear();
|
||||
|
||||
$this->initialize();
|
||||
return $response;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
$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->cache->commit();
|
||||
}
|
||||
|
||||
return $this->cache->deleteItem($key);
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
$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);
|
||||
}
|
||||
}
|
||||
$keyList->set($keyValues);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->saveDeferred($isDirty);
|
||||
$this->cache->commit();
|
||||
|
||||
return $this->cache->deleteItems($keys);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function save(CacheItemInterface $item): bool {
|
||||
$this->update($item);
|
||||
return $this->cache->save($item);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function saveDeferred(CacheItemInterface $item): bool {
|
||||
$this->update($item);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
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;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $requestPool,
|
||||
CacheItemPoolInterface $persistRequestPool,
|
||||
CacheItemPoolInterface $sessionPool,
|
||||
CacheItemPoolInterface $persistSessionPool
|
||||
) {
|
||||
$this->requestPool = new MonitorCacheKeys($requestPool);
|
||||
$this->persistRequestPool = new MonitorCacheKeys($persistRequestPool);
|
||||
$this->sessionPool = new MonitorCacheKeys($sessionPool);
|
||||
$this->persistSessionPool = new MonitorCacheKeys($persistSessionPool);
|
||||
}
|
||||
|
||||
/** @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());
|
||||
foreach ($items as $item) {
|
||||
$this->requestPool->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();
|
||||
}
|
||||
}
|
||||
|
||||
/** @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();
|
||||
foreach ($items as $item) {
|
||||
$this->persistRequestPool->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use BaconQrCode\Renderer\PlainTextRenderer;
|
||||
use BaconQrCode\Writer;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
final readonly class Utilities {
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private CacheItemPoolInterface $appPool,
|
||||
) {}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function loadTotp(): string {
|
||||
/* user forgot to set their TOTP_URI in the environment */
|
||||
if ($this->appPool->hasItem('totp')) {
|
||||
$totp = $this->appPool->getItem('totp')->get();
|
||||
} else {
|
||||
$totp = $this->makeTotp();
|
||||
}
|
||||
|
||||
$this->showTotp($totp);
|
||||
return $totp;
|
||||
}
|
||||
|
||||
/** @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->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->appPool->save($totpItem);
|
||||
return $totp;
|
||||
}
|
||||
|
||||
private function showTotp(string $totp): void {
|
||||
// /* only show this at most, every 5 minutes */
|
||||
// $suppress = $this->appPool->getItem('suppress');
|
||||
// if ( ! $suppress->isHit()) {
|
||||
$writer = new Writer(new PlainTextRenderer());
|
||||
file_put_contents(
|
||||
'php://stderr', <<<RAW
|
||||
{$writer->writeString($totp)}
|
||||
$totp
|
||||
loading totp, because the env is not set, please copy above into TOTP_URI
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"symfony/console": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "2.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.4",
|
||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||
},
|
||||
"files": [
|
||||
".env",
|
||||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php",
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
use Preauth\Auth;
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo $env->getTitle(); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Begin Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
|
||||
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
|
||||
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
|
||||
<!-- End Icons -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
}
|
||||
html {
|
||||
background-color: <?php echo $env->getColor(); ?>;
|
||||
color: <?php echo $env->getTextColor(); ?>;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
font-size: 1.5em;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
form div {
|
||||
width: 45%;
|
||||
}
|
||||
div.right {
|
||||
text-align: right;
|
||||
}
|
||||
div.center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><?php echo $env->getTitle(); ?></h1>
|
||||
<form action="/" method="get">
|
||||
<input type="hidden" name="<?php echo Auth::RETURN_FIELD; ?>" value="<?php echo $auth->getReturnTo(); ?>">
|
||||
<div class="right"><label for="id"><?php echo $env->getIdName(); ?>:</label></div>
|
||||
<div><input type="text" name="<?php echo Auth::ID_FIELD; ?>" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
|
||||
<div class="right"><label for="token"><?php echo $env->getTokenName(); ?>:</label></div>
|
||||
<div><input type="text" name="<?php echo Auth::TOKEN_FIELD; ?>" id="token" autocomplete="off" required="required"></div>
|
||||
<div class="center"><button type="submit"><?php echo $env->getSubmitName(); ?></button></div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<style>
|
||||
* { margin: 0; padding: 0.25em; }
|
||||
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
|
||||
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
|
||||
body { display: table-cell; vertical-align: middle; }
|
||||
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
|
||||
p { color: {{ env.error_color }}; text-align: center; }
|
||||
form { display: flex; flex-wrap: wrap; justify-content: center; }
|
||||
form div { width: 45%; }
|
||||
div.right { text-align: right; }
|
||||
div.center { text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ env.title }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{{ include('_style.html.twig') }}
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% if env.teapot|default(true) %}
|
||||
<h1>{{ env.teapot_title }}</h1>
|
||||
<p>{{ env.teapot_message }}</p>
|
||||
{% else %}
|
||||
<h1>{{ env.too_many_title }}</h1>
|
||||
<p>{{ env.too_many_message }}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ env.title }}</h1>
|
||||
<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