Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db0cf77049 | ||
|
|
ef924472a3 | ||
|
|
539385c438 | ||
|
|
eed6b4dbff | ||
|
|
2b61a43f60 | ||
|
|
abe94c6238 | ||
|
|
1e186c9354 | ||
|
|
2064153cd3 | ||
|
|
054b8ef48f | ||
|
|
5258e175a1 | ||
|
|
2f7ae31ba1 | ||
|
|
baf976a8e6 | ||
|
|
af4d2a4ac7 | ||
|
|
c743a1baac | ||
|
|
3f1778cd6b | ||
|
|
33181f11d8 | ||
|
|
bb2cc3ce49 | ||
|
|
e4f54769e6 | ||
|
|
b75a16a781 | ||
|
|
9111958bcf | ||
|
|
95dc6bf0ce | ||
|
|
472abfdf89 | ||
|
|
e2780ca5f6 | ||
|
|
7a68c933ce | ||
|
|
55f8e9e84c | ||
|
|
e3cd8c6739 | ||
|
|
235a7866b3 | ||
|
|
c7585e720a | ||
|
|
66b960ccea | ||
|
|
17c2d525ff | ||
|
|
72c41fec77 | ||
|
|
29e471c536 | ||
|
|
44e4c60f80 | ||
|
|
5563999525 | ||
|
|
9ad54f8e2a | ||
|
|
2258839bd6 | ||
|
|
408d75dda1 | ||
|
|
b89070e985 | ||
|
|
d2eb914637 | ||
|
|
c0bda8aeec | ||
|
|
cb378e20bc | ||
|
|
6b5a711fa9 | ||
|
|
95ab77db2a | ||
|
|
d6bcbf661e | ||
|
|
de2a382cbf | ||
|
|
12ba6cde7b | ||
|
|
6c5a7c98e8 | ||
|
|
4d314bcb28 | ||
|
|
890cc225ef | ||
|
|
3c1253ee45 | ||
|
|
70bf811b1d | ||
|
|
3269151e9b | ||
|
|
1da2188bdf | ||
|
|
7cf7e04d17 |
Executable
+465
@@ -0,0 +1,465 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# conformance.sh — checks a Symfony project against the shared standard.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# conformance.sh --profile=web-app|auth-gateway|api-gateway [--json] [path]
|
||||||
|
#
|
||||||
|
# Design rules (GUIDING-LIGHT §8.2):
|
||||||
|
# 1. It only CHECKS. It never fixes anything. No remediation logic to maintain.
|
||||||
|
# 2. Checks are ADDED, never removed. The script can only get stricter. If a
|
||||||
|
# check is wrong, fix the check — don't delete it from a repo.
|
||||||
|
# 3. Every check names the document section it comes from, so a failure tells
|
||||||
|
# you WHY the rule exists, not just that you broke it.
|
||||||
|
#
|
||||||
|
# Exit codes: 0 = all passed, 1 = at least one failure.
|
||||||
|
#
|
||||||
|
# DEPENDENCIES: bash + coreutils + grep for everything except one check.
|
||||||
|
# `controls-16px-min-css` shells out to css-control-size.py because resolving
|
||||||
|
# rem/em/font-shorthand units correctly is not something grep can do — and
|
||||||
|
# getting it wrong silently misses the single most important regression in
|
||||||
|
# this codebase (vital-pulse's 0.95rem inputs). python3 is present on every
|
||||||
|
# GitHub/Gitea runner and in the setup-php images, so this is a safe
|
||||||
|
# dependency; if it is ever missing, that one check is SKIPPED with a warning
|
||||||
|
# rather than failed, so the rest of the suite still runs.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PROFILE=""
|
||||||
|
OUTPUT_JSON=false
|
||||||
|
TARGET=""
|
||||||
|
|
||||||
|
# Directory this script lives in, so helper tools can be located regardless of
|
||||||
|
# the CWD the caller is in (CI runs it from the project root).
|
||||||
|
CONFORMANCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
export CONFORMANCE_DIR
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--profile=*) PROFILE="${arg#*=}" ;;
|
||||||
|
--json) OUTPUT_JSON=true ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-*) echo "Unknown flag: $arg" >&2; exit 2 ;;
|
||||||
|
*) TARGET="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
PROFILE="${PROFILE:-web-app}"
|
||||||
|
TARGET="${TARGET:-.}"
|
||||||
|
cd "$TARGET" || { echo "Cannot cd to $TARGET" >&2; exit 2; }
|
||||||
|
|
||||||
|
case "$PROFILE" in
|
||||||
|
web-app|auth-gateway|api-gateway) ;;
|
||||||
|
*) echo "Invalid --profile: $PROFILE (expected web-app, auth-gateway, or api-gateway)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
SKIP=0
|
||||||
|
declare -a FAILURES=()
|
||||||
|
declare -a SKIPPED=()
|
||||||
|
|
||||||
|
# check <id> <description> <doc-section> <test-command...>
|
||||||
|
# The test command must exit 0 to pass.
|
||||||
|
check() {
|
||||||
|
local id="$1" desc="$2" section="$3"
|
||||||
|
shift 3
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
$OUTPUT_JSON || printf ' \033[32m✓\033[0m %s\n' "$id"
|
||||||
|
else
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
FAILURES+=("$id|$desc|$section")
|
||||||
|
$OUTPUT_JSON || printf ' \033[31m✗\033[0m %s — %s [%s]\n' "$id" "$desc" "$section"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# check_opt <id> <desc> <section> <prereq-cmd> <real-cmd...>
|
||||||
|
#
|
||||||
|
# For checks that depend on optional tooling. If the prerequisite is missing
|
||||||
|
# the check is reported as SKIPPED — its own state, never a green tick.
|
||||||
|
# A silently-passing check is the most dangerous outcome here: it makes a repo
|
||||||
|
# look compliant while the check never actually ran.
|
||||||
|
check_opt() {
|
||||||
|
local id="$1" desc="$2" section="$3" prereq="$4"
|
||||||
|
shift 4
|
||||||
|
# `prereq` is a SHELL EXPRESSION STRING, evaluated with eval.
|
||||||
|
#
|
||||||
|
# It must not be a multi-word command: `$prereq` is a single variable, so
|
||||||
|
# passing `bash -c '...'` would bind only `bash` and run it bare — and a
|
||||||
|
# bare `bash` reads stdin and BLOCKS FOREVER. That turns a broken check into
|
||||||
|
# a hung CI job (which only surfaces when the job timeout kills it).
|
||||||
|
if ! eval "$prereq" >/dev/null 2>&1; then
|
||||||
|
SKIP=$((SKIP + 1))
|
||||||
|
SKIPPED+=("$id|$desc|$section")
|
||||||
|
$OUTPUT_JSON || printf ' \033[33m–\033[0m %s — SKIPPED (prerequisite unavailable) [%s]\n' "$id" "$section"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
check "$id" "$desc" "$section" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# has_file <path>
|
||||||
|
has_file() { [ -f "$1" ]; }
|
||||||
|
# not_has_file <path>
|
||||||
|
not_has_file() { [ ! -f "$1" ]; }
|
||||||
|
# file_contains <path> <pattern> (silently false if path missing)
|
||||||
|
file_contains() { [ -f "$1" ] && grep -qE "$2" "$1"; }
|
||||||
|
# any_file_contains <pattern> <path...>
|
||||||
|
#
|
||||||
|
# NOTE: matches anywhere in the file, INCLUDING comments. That is correct for
|
||||||
|
# rules about the mere presence of a string, and WRONG for rules about a
|
||||||
|
# directive. When checking a directive ("CI calls X"), anchor the pattern to
|
||||||
|
# the line form that the directive actually takes — see ci-reusable-workflows
|
||||||
|
# below for why that matters.
|
||||||
|
any_file_contains() {
|
||||||
|
local pat="$1"; shift
|
||||||
|
grep -rlE "$pat" "$@" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
# no_file_contains <ext-glob> <pattern> — searches source trees only
|
||||||
|
no_match_in_sources() {
|
||||||
|
local pattern="$1"; shift
|
||||||
|
grep -rEl "$pattern" "$@" >/dev/null 2>&1 && return 1 || return 0
|
||||||
|
}
|
||||||
|
# dir_exists
|
||||||
|
dir_exists() { [ -d "$1" ]; }
|
||||||
|
|
||||||
|
$OUTPUT_JSON || {
|
||||||
|
echo ""
|
||||||
|
echo "Conformance check — profile: $PROFILE — $(pwd)"
|
||||||
|
echo "Standard: GUIDING-LIGHT.md"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# UNIVERSAL — every repo, every archetype
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
$OUTPUT_JSON || echo "PHP & framework baseline"
|
||||||
|
|
||||||
|
check "php-85" \
|
||||||
|
"composer.json requires PHP 8.5 (use ^8.5, not >=8.4)" \
|
||||||
|
"§1.1" \
|
||||||
|
file_contains composer.json '"php"[[:space:]]*:[[:space:]]*"\^8\.5'
|
||||||
|
|
||||||
|
check "php-not-open-ended" \
|
||||||
|
"PHP constraint is not open-ended (>=8.4 allows PHP 9)" \
|
||||||
|
"§1.1" \
|
||||||
|
bash -c '! grep -qE "\"php\"[[:space:]]*:[[:space:]]*\">=" composer.json'
|
||||||
|
|
||||||
|
check "platform-pinned" \
|
||||||
|
"config.platform is set in composer.json (prevents silent version drift)" \
|
||||||
|
"§1.2" \
|
||||||
|
file_contains composer.json '"platform"'
|
||||||
|
|
||||||
|
check "symfony-81" \
|
||||||
|
"Symfony pinned to 8.1" \
|
||||||
|
"§1" \
|
||||||
|
file_contains composer.json 'symfony/framework-bundle":[[:space:]]*"8\.1\.'
|
||||||
|
|
||||||
|
$OUTPUT_JSON || echo ""
|
||||||
|
$OUTPUT_JSON || echo "Toolchain"
|
||||||
|
|
||||||
|
check "phpstan-config" \
|
||||||
|
"phpstan.neon.dist present" \
|
||||||
|
"§2.2" \
|
||||||
|
has_file phpstan.neon.dist
|
||||||
|
|
||||||
|
check "phpstan-level" \
|
||||||
|
"PHPStan level >= 6 declared" \
|
||||||
|
"§2.2" \
|
||||||
|
bash -c 'grep -qE "level:[[:space:]]*[6-9]|level:[[:space:]]*max" phpstan.neon.dist 2>/dev/null'
|
||||||
|
|
||||||
|
check "cs-fixer-config" \
|
||||||
|
".php-cs-fixer.dist.php present" \
|
||||||
|
"§2.5" \
|
||||||
|
has_file .php-cs-fixer.dist.php
|
||||||
|
|
||||||
|
check "cs-fixer-pinned" \
|
||||||
|
"friendsofphp/php-cs-fixer pinned to ^3.95 (not \"*\")" \
|
||||||
|
"§2.5" \
|
||||||
|
bash -c '! grep -qE "php-cs-fixer\"[[:space:]]*:[[:space:]]*\"\*\"" composer.json'
|
||||||
|
|
||||||
|
check "phpunit-config-name" \
|
||||||
|
"PHPUnit config named phpunit.dist.xml" \
|
||||||
|
"§2.4" \
|
||||||
|
has_file phpunit.dist.xml
|
||||||
|
|
||||||
|
check "editorconfig" \
|
||||||
|
".editorconfig present" \
|
||||||
|
"§8.10" \
|
||||||
|
has_file .editorconfig
|
||||||
|
|
||||||
|
$OUTPUT_JSON || echo ""
|
||||||
|
$OUTPUT_JSON || echo "CI & supply chain"
|
||||||
|
|
||||||
|
check "ci-composer-audit" \
|
||||||
|
"CI runs 'composer audit'" \
|
||||||
|
"§8.1" \
|
||||||
|
any_file_contains 'composer audit' .gitea/workflows
|
||||||
|
|
||||||
|
check "ci-coverage" \
|
||||||
|
"CI measures test coverage" \
|
||||||
|
"§2.3" \
|
||||||
|
any_file_contains 'coverage' .gitea/workflows
|
||||||
|
|
||||||
|
check "ci-composer-validate" \
|
||||||
|
"CI runs 'composer validate --strict'" \
|
||||||
|
"§8.3" \
|
||||||
|
any_file_contains 'composer validate' .gitea/workflows
|
||||||
|
|
||||||
|
# Anchored to a REAL `uses:` line, not the string anywhere in the file.
|
||||||
|
#
|
||||||
|
# The previous form matched the string anywhere, which gave it a false-positive
|
||||||
|
# mode that made it worse than useless: a repo that INLINED the shared workflow
|
||||||
|
# still carries a header comment saying it was "inlined from
|
||||||
|
# private/ci/.gitea/workflows/...", so the check went green in exactly the repos
|
||||||
|
# that had drifted. It reported compliance precisely where compliance was absent.
|
||||||
|
#
|
||||||
|
# `uses:` is what the description always claimed to test.
|
||||||
|
check "ci-reusable-workflows" \
|
||||||
|
"CI calls shared workflows from private/ci (not five drift surfaces)" \
|
||||||
|
"§8.2" \
|
||||||
|
any_file_contains '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows' .gitea/workflows
|
||||||
|
|
||||||
|
$OUTPUT_JSON || echo ""
|
||||||
|
$OUTPUT_JSON || echo "Hygiene & layout"
|
||||||
|
|
||||||
|
check "dockerignore-env" \
|
||||||
|
".env is excluded in .dockerignore (prevents secrets in images)" \
|
||||||
|
"§6.1" \
|
||||||
|
file_contains .dockerignore '^/?\.env$'
|
||||||
|
|
||||||
|
check "dockerignore-present" \
|
||||||
|
".dockerignore present" \
|
||||||
|
"§6" \
|
||||||
|
has_file .dockerignore
|
||||||
|
|
||||||
|
check "gitignore-var" \
|
||||||
|
"/var/ excluded in .gitignore" \
|
||||||
|
"§5.2" \
|
||||||
|
file_contains .gitignore '^/?var/?$'
|
||||||
|
|
||||||
|
check "dockerfile-nonroot" \
|
||||||
|
"Dockerfile drops privileges with USER" \
|
||||||
|
"§6.4" \
|
||||||
|
file_contains Dockerfile '^USER '
|
||||||
|
|
||||||
|
check "dockerfile-pinned-base" \
|
||||||
|
"Dockerfile base image is not :latest" \
|
||||||
|
"§6.4" \
|
||||||
|
bash -c '! grep -qE "^FROM[^ ]*:latest" Dockerfile'
|
||||||
|
|
||||||
|
check "docs-examples" \
|
||||||
|
"docs/examples/ present" \
|
||||||
|
"§4.4" \
|
||||||
|
dir_exists docs/examples
|
||||||
|
|
||||||
|
check "boilerplate-security" \
|
||||||
|
"SECURITY.md present" \
|
||||||
|
"§7.1" \
|
||||||
|
has_file SECURITY.md
|
||||||
|
|
||||||
|
check "license-file" \
|
||||||
|
"LICENSE file present" \
|
||||||
|
"§7.2" \
|
||||||
|
has_file LICENSE
|
||||||
|
|
||||||
|
check "license-mit" \
|
||||||
|
"LICENSE is MIT (uniform MIT decided §7.2)" \
|
||||||
|
"§7.2" \
|
||||||
|
bash -c 'head -1 LICENSE 2>/dev/null | grep -qi "^MIT License"'
|
||||||
|
|
||||||
|
# A composer.json license that contradicts the shipped LICENSE file is worse
|
||||||
|
# than declaring none: tooling trusts the metadata, humans read the file.
|
||||||
|
# task-weaver declared "proprietary" while shipping no file at all.
|
||||||
|
check "license-declared-matches" \
|
||||||
|
"composer.json declares MIT, matching the LICENSE file" \
|
||||||
|
"§7.2" \
|
||||||
|
file_contains composer.json '"license"[[:space:]]*:[[:space:]]*"MIT"'
|
||||||
|
|
||||||
|
check "no-cdn-references" \
|
||||||
|
"No CDN script/link references (zero-CDN goal)" \
|
||||||
|
"§3.5" \
|
||||||
|
bash -c '! grep -rElE "(cdn\.jsdelivr|cdnjs\.cloudflare|unpkg\.com|cdn\.tailwindcss)" templates public assets config 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# NOT API-GATEWAY — anything that renders HTML
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$PROFILE" != "api-gateway" ]; then
|
||||||
|
$OUTPUT_JSON || echo ""
|
||||||
|
$OUTPUT_JSON || echo "Accessibility (§3.3a) — the iOS zoom root cause"
|
||||||
|
|
||||||
|
check "viewport-not-zoom-locked" \
|
||||||
|
"Viewport does not disable pinch-zoom (WCAG 1.4.4)" \
|
||||||
|
"§3.3a" \
|
||||||
|
bash -c '! grep -rElE "(user-scalable=no|maximum-scale=1)" templates public 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
check "viewport-fit-cover" \
|
||||||
|
"Viewport declares viewport-fit=cover (safe areas)" \
|
||||||
|
"§3.3a" \
|
||||||
|
bash -c 'grep -rElE "viewport-fit=cover" templates public 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
# Two checks, because there are two ways to get this wrong and one grep
|
||||||
|
# cannot see both:
|
||||||
|
# (a) raw CSS with a small font-size — needs unit resolution (rem/em/shorthand)
|
||||||
|
# (b) Tailwind-style utility classes on the control — not CSS at all
|
||||||
|
# Requires python3 AND the vendored helper. If either is absent this is
|
||||||
|
# SKIPPED (reported as its own state), never a green tick.
|
||||||
|
check_opt "controls-16px-min-css" \
|
||||||
|
"No form control renders below 16px (root cause of iOS auto-zoom)" \
|
||||||
|
"§3.3a" \
|
||||||
|
'command -v python3 >/dev/null 2>&1 && [ -f "$CONFORMANCE_DIR/css-control-size.py" ]' \
|
||||||
|
bash -c '
|
||||||
|
mapfile -t files < <(find templates assets public -type f \( -name "*.css" -o -name "*.twig" -o -name "*.html" \) 2>/dev/null)
|
||||||
|
[ "${#files[@]}" -eq 0 ] && exit 0
|
||||||
|
exec "$CONFORMANCE_DIR/css-control-size.py" 16 "${files[@]}"
|
||||||
|
'
|
||||||
|
|
||||||
|
check "controls-16px-min-utility" \
|
||||||
|
"No small text utility class on a form control (Tailwind text-xs/text-sm)" \
|
||||||
|
"§3.3a" \
|
||||||
|
bash -c '
|
||||||
|
! grep -rPzoE "<(input|select|textarea)[^>]*class=\"[^\"]*(text-xs|text-sm)[^\"]*\"" templates 2>/dev/null | head -c1 | grep -q .
|
||||||
|
'
|
||||||
|
|
||||||
|
check "security-headers" \
|
||||||
|
"Security headers configured (Symfony listener or Caddy)" \
|
||||||
|
"§8.9" \
|
||||||
|
bash -c 'grep -rElE "X-Content-Type-Options" src config docker Caddyfile 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
check "no-deprecated-xss-header" \
|
||||||
|
"X-XSS-Protection not used (deprecated)" \
|
||||||
|
"§8.9" \
|
||||||
|
bash -c '! grep -rElE "X-XSS-Protection" src config docker Caddyfile 2>/dev/null | head -1 | grep -q .'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# WEB-APP ONLY — installable PWA surface
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$PROFILE" = "web-app" ]; then
|
||||||
|
$OUTPUT_JSON || echo ""
|
||||||
|
$OUTPUT_JSON || echo "PWA (§3.3b)"
|
||||||
|
|
||||||
|
check "webmanifest" \
|
||||||
|
"Web app manifest present" \
|
||||||
|
"§3.3b" \
|
||||||
|
bash -c 'ls public/*.webmanifest public/manifest.json 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
check "manifest-display-standalone" \
|
||||||
|
"Manifest declares display: standalone" \
|
||||||
|
"§3.3b" \
|
||||||
|
bash -c 'grep -qE "\"display\"[[:space:]]*:[[:space:]]*\"standalone\"" public/*.webmanifest public/manifest.json 2>/dev/null'
|
||||||
|
|
||||||
|
check "manifest-start-url" \
|
||||||
|
"Manifest declares start_url" \
|
||||||
|
"§3.3b" \
|
||||||
|
bash -c 'grep -qE "\"start_url\"" public/*.webmanifest public/manifest.json 2>/dev/null'
|
||||||
|
|
||||||
|
check "service-worker" \
|
||||||
|
"Service worker present (required for installability)" \
|
||||||
|
"§3.3b" \
|
||||||
|
bash -c 'ls public/sw.js public/service-worker.js 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
check "theme-color" \
|
||||||
|
"theme-color meta present (status bar theming)" \
|
||||||
|
"§3.3a" \
|
||||||
|
bash -c 'grep -rElE "theme-color" templates public 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
check "touch-action" \
|
||||||
|
"touch-action: manipulation used (removes 300ms tap delay)" \
|
||||||
|
"§3.3a" \
|
||||||
|
bash -c 'grep -rElE "touch-action" templates assets public 2>/dev/null | head -1 | grep -q .'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# AUTH-GATEWAY ONLY — preauth-specific security invariants
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$PROFILE" = "auth-gateway" ]; then
|
||||||
|
$OUTPUT_JSON || echo ""
|
||||||
|
$OUTPUT_JSON || echo "Auth gateway security (§3.3d)"
|
||||||
|
|
||||||
|
check "no-service-worker" \
|
||||||
|
"Auth gateway has NO service worker (must never replay a cached session)" \
|
||||||
|
"§3.3d" \
|
||||||
|
bash -c '! ls public/sw.js public/service-worker.js 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
check "no-store-present" \
|
||||||
|
"no-store cache directive present somewhere (login flow anti-cache guard)" \
|
||||||
|
"§3.3d" \
|
||||||
|
bash -c 'grep -rEli "no-store" src config 2>/dev/null | head -1 | grep -q .'
|
||||||
|
|
||||||
|
check "rate-limiter" \
|
||||||
|
"symfony/rate-limiter required" \
|
||||||
|
"§8.11" \
|
||||||
|
file_contains composer.json 'rate-limiter'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# REPORT
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
TOTAL=$((PASS + FAIL + SKIP))
|
||||||
|
|
||||||
|
# JSON strings must be escaped. Without this, a description containing a double
|
||||||
|
# quote (e.g. 'pinned to ^3.95 (not "*")') produces invalid JSON and silently
|
||||||
|
# breaks every consumer of --json.
|
||||||
|
json_escape() {
|
||||||
|
local s="$1"
|
||||||
|
s="${s//\\/\\\\}" # backslash first
|
||||||
|
s="${s//\"/\\\"}" # then double quote
|
||||||
|
s="${s//$'\n'/\\n}"
|
||||||
|
s="${s//$'\t'/\\t}"
|
||||||
|
s="${s//$'\r'/}"
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
if $OUTPUT_JSON; then
|
||||||
|
printf '{"profile":"%s","passed":%d,"failed":%d,"skipped":%d,"total":%d,"failures":[' \
|
||||||
|
"$(json_escape "$PROFILE")" "$PASS" "$FAIL" "$SKIP" "$TOTAL"
|
||||||
|
first=true
|
||||||
|
for f in "${FAILURES[@]:-}"; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
IFS='|' read -r fid fdesc fsec <<< "$f"
|
||||||
|
$first || printf ','
|
||||||
|
printf '{"id":"%s","description":"%s","section":"%s"}' \
|
||||||
|
"$(json_escape "$fid")" "$(json_escape "$fdesc")" "$(json_escape "$fsec")"
|
||||||
|
first=false
|
||||||
|
done
|
||||||
|
printf '],"skipped_checks":['
|
||||||
|
first=true
|
||||||
|
for f in "${SKIPPED[@]:-}"; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
IFS='|' read -r fid fdesc fsec <<< "$f"
|
||||||
|
$first || printf ','
|
||||||
|
printf '{"id":"%s","description":"%s","section":"%s"}' \
|
||||||
|
"$(json_escape "$fid")" "$(json_escape "$fdesc")" "$(json_escape "$fsec")"
|
||||||
|
first=false
|
||||||
|
done
|
||||||
|
printf ']}\n'
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "─────────────────────────────────────────────"
|
||||||
|
if [ "$SKIP" -gt 0 ]; then
|
||||||
|
printf ' \033[33m%d of %d checks SKIPPED\033[0m (missing prerequisites — NOT a pass):\n' "$SKIP" "$TOTAL"
|
||||||
|
for f in "${SKIPPED[@]}"; do
|
||||||
|
IFS='|' read -r fid fdesc fsec <<< "$f"
|
||||||
|
printf ' · [%s] %s\n' "$fsec" "$fid"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
if [ "$FAIL" -eq 0 ]; then
|
||||||
|
printf ' \033[32mAll %d runnable checks passed.\033[0m\n' "$((TOTAL - SKIP))"
|
||||||
|
else
|
||||||
|
printf ' \033[31m%d of %d checks failed.\033[0m\n\n' "$FAIL" "$TOTAL"
|
||||||
|
echo " Guidance:"
|
||||||
|
for f in "${FAILURES[@]}"; do
|
||||||
|
IFS='|' read -r fid fdesc fsec <<< "$f"
|
||||||
|
printf ' • [%s] %s\n %s\n' "$fsec" "$fdesc" "$fid"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo "─────────────────────────────────────────────"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
||||||
Executable
+228
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
css-control-size.py — finds form controls whose rendered font-size is below 16px.
|
||||||
|
|
||||||
|
This is the precise version of the check. A naive `grep font-size` on the source
|
||||||
|
produces both false negatives and false positives:
|
||||||
|
|
||||||
|
FALSE NEGATIVE: vital-pulse declares `font-size: 0.95rem` on its inputs.
|
||||||
|
0.95rem x 16px = 15.2px — under the iOS auto-zoom threshold
|
||||||
|
— but it never appears as a `px` literal.
|
||||||
|
|
||||||
|
FALSE POSITIVE: task-weaver has many `font-size: 11px` rules for table
|
||||||
|
headers and labels. Those are fine. It's *form controls*
|
||||||
|
that matter, so only rules whose selector targets a control
|
||||||
|
should be judged.
|
||||||
|
|
||||||
|
FALSE POSITIVE: preauth declares `button, input { font-size: 0.9em }`, which
|
||||||
|
looks small as an `em` value — but its root is
|
||||||
|
`html { font-size: 1.5em }` = 24px, so the real rendered
|
||||||
|
size is 21.6px. `em` must be resolved against the parsed root.
|
||||||
|
|
||||||
|
So this walks CSS rules, keeps only control-targeting selectors, resolves
|
||||||
|
px / rem / em to a pixel value, and reports anything under the threshold.
|
||||||
|
|
||||||
|
Usage: css-control-size.py <threshold-px> <file> [file...]
|
||||||
|
Output: one line per violation: "<file>:<line-ish>\t<selector>\t<raw>\t<computed-px>"
|
||||||
|
Exit: 0 = clean, 1 = violations found
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Selectors that style an actual interactive form control.
|
||||||
|
CONTROL_RE = re.compile(
|
||||||
|
r"""(?ix)
|
||||||
|
(?:^|[\s,>+~\[.(#]) # boundary
|
||||||
|
(?:
|
||||||
|
input | select | textarea | button
|
||||||
|
| \.input\b | \.form-control\b | \.btn\b
|
||||||
|
| \[type= | \.tag-input\b | \.schedule-number\b
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# font-size: 14px | font-size: 0.95rem | font-size: 0.9em
|
||||||
|
FONT_SIZE_RE = re.compile(
|
||||||
|
r"font-size\s*:\s*(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# font: 14px/1.5 ... (shorthand — sets font-size implicitly)
|
||||||
|
FONT_SHORTHAND_RE = re.compile(
|
||||||
|
r"font\s*:\s*(?:[^;{}]*?\s)?(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# html { font-size: 1.5em } — establishes the em base.
|
||||||
|
ROOT_SELECTOR_RE = re.compile(r"(?i)^\s*(?:html|:root)\s*$")
|
||||||
|
|
||||||
|
DEFAULT_ROOT_PX = 16.0
|
||||||
|
PT_TO_PX = 4.0 / 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(css: str) -> str:
|
||||||
|
return re.sub(r"/\*.*?\*/", " ", css, flags=re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rules(css: str):
|
||||||
|
"""Yield (selector, body, index) for every rule, including nested ones."""
|
||||||
|
css = strip_comments(css)
|
||||||
|
stack: list[str] = []
|
||||||
|
acc = ""
|
||||||
|
for i, ch in enumerate(css):
|
||||||
|
if ch == "{":
|
||||||
|
stack.append(acc.strip())
|
||||||
|
acc = ""
|
||||||
|
elif ch == "}":
|
||||||
|
selector = stack.pop() if stack else ""
|
||||||
|
if selector:
|
||||||
|
# Body is what accumulated inside this rule; recover it from
|
||||||
|
# the source between the opening brace and here.
|
||||||
|
yield selector, "", i
|
||||||
|
acc = ""
|
||||||
|
else:
|
||||||
|
acc += ch
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rules_with_bodies(css: str):
|
||||||
|
"""Yield (selector, body). Handles nesting (@media) by tracking depth."""
|
||||||
|
css = strip_comments(css)
|
||||||
|
stack: list[str] = []
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
acc = ""
|
||||||
|
body_start: list[int] = []
|
||||||
|
for i, ch in enumerate(css):
|
||||||
|
if ch == "{":
|
||||||
|
stack.append(acc.strip())
|
||||||
|
body_start.append(i + 1)
|
||||||
|
acc = ""
|
||||||
|
elif ch == "}":
|
||||||
|
if stack:
|
||||||
|
selector = stack.pop()
|
||||||
|
start = body_start.pop() if body_start else 0
|
||||||
|
out.append((selector, css[start:i]))
|
||||||
|
acc = ""
|
||||||
|
else:
|
||||||
|
acc += ch
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_px(value: float, unit: str, root_px: float) -> float:
|
||||||
|
unit = unit.lower()
|
||||||
|
if unit == "px":
|
||||||
|
return value
|
||||||
|
if unit == "rem":
|
||||||
|
return value * DEFAULT_ROOT_PX
|
||||||
|
if unit == "em":
|
||||||
|
# Resolved against the root that we parsed. This is an approximation
|
||||||
|
# (true `em` is parent-relative) but it is correct for the real case
|
||||||
|
# that matters: a page-level `html { font-size: N }` scaling controls.
|
||||||
|
return value * root_px
|
||||||
|
if unit == "pt":
|
||||||
|
return value * PT_TO_PX
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def find_root_px(rules) -> float:
|
||||||
|
"""Find an explicit html/:root font-size to use as the em base."""
|
||||||
|
for selector, body in rules:
|
||||||
|
sel = selector.split(",")[0].strip()
|
||||||
|
if ROOT_SELECTOR_RE.match(sel):
|
||||||
|
m = FONT_SIZE_RE.search(body)
|
||||||
|
if m:
|
||||||
|
val = float(m.group("val"))
|
||||||
|
unit = m.group("unit")
|
||||||
|
# Root em is relative to the 16px default.
|
||||||
|
return resolve_px(val, unit, DEFAULT_ROOT_PX)
|
||||||
|
return DEFAULT_ROOT_PX
|
||||||
|
|
||||||
|
|
||||||
|
def check_file(path: str, threshold: float) -> list[tuple[str, str, str, float]]:
|
||||||
|
violations: list[tuple[str, str, str, float]] = []
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
css = fh.read()
|
||||||
|
except OSError:
|
||||||
|
return violations
|
||||||
|
|
||||||
|
# Only look at things that plausibly contain CSS.
|
||||||
|
if "{" not in css:
|
||||||
|
return violations
|
||||||
|
|
||||||
|
rules = parse_rules_with_bodies(css)
|
||||||
|
root_px = find_root_px(rules)
|
||||||
|
|
||||||
|
for selector, body in rules:
|
||||||
|
# Skip at-rule wrappers; their inner rules are yielded separately.
|
||||||
|
head = selector.strip()
|
||||||
|
if head.startswith("@"):
|
||||||
|
continue
|
||||||
|
# A rule may have several comma-separated selectors; judge each.
|
||||||
|
for one in head.split(","):
|
||||||
|
one = one.strip()
|
||||||
|
if not one:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_inherited_base = bool(re.match(r"(?i)^(?:html|body)$", one))
|
||||||
|
targets_control = bool(CONTROL_RE.search(one))
|
||||||
|
if not targets_control and not is_inherited_base:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ignore rules that only set colours etc. — we want font-size.
|
||||||
|
m = FONT_SIZE_RE.search(body)
|
||||||
|
if m:
|
||||||
|
val = float(m.group("val"))
|
||||||
|
unit = m.group("unit")
|
||||||
|
raw = f"{m.group('val')}{unit}"
|
||||||
|
elif is_inherited_base:
|
||||||
|
# `body { font: 14px/1.5 }` sets the base every unstyled control
|
||||||
|
# inherits. Only the shorthand carries a size here.
|
||||||
|
m2 = FONT_SHORTHAND_RE.search(body)
|
||||||
|
if not m2:
|
||||||
|
continue
|
||||||
|
val = float(m2.group("val"))
|
||||||
|
unit = m2.group("unit")
|
||||||
|
raw = f"font-shorthand {m2.group('val')}{unit}"
|
||||||
|
else:
|
||||||
|
# A control-targeting rule with no size of its own inherits
|
||||||
|
# whatever body provides, which is reported separately.
|
||||||
|
continue
|
||||||
|
|
||||||
|
computed = resolve_px(val, unit, root_px)
|
||||||
|
if computed < threshold:
|
||||||
|
violations.append((path, one, raw, computed))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(__doc__, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
threshold = float(sys.argv[1])
|
||||||
|
except ValueError:
|
||||||
|
print(f"threshold must be a number, got {sys.argv[1]!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for path in sys.argv[2:]:
|
||||||
|
for vpath, selector, raw, computed in check_file(path, threshold):
|
||||||
|
print(f"{vpath}\t{selector}\t{raw}\t{computed:.1f}px")
|
||||||
|
total += 1
|
||||||
|
|
||||||
|
if total:
|
||||||
|
print(
|
||||||
|
f"\n{total} control style(s) render below {threshold:.0f}px. "
|
||||||
|
"iOS Safari auto-zooms any focused control under 16px — this is the "
|
||||||
|
"trigger that `user-scalable=no` was masking (GUIDING-LIGHT §3.3a).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
var/
|
||||||
|
vendor/
|
||||||
|
tests/
|
||||||
|
.phpunit.cache/
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.env.local
|
||||||
|
composer.phar
|
||||||
+41
-5
@@ -1,20 +1,56 @@
|
|||||||
# editorconfig.org
|
# editorconfig.org
|
||||||
|
#
|
||||||
|
# Canonical shared .editorconfig. Copy verbatim into a project root.
|
||||||
|
# LEAF file: sync = overwrite, never merge (GUIDING-LIGHT §8.2).
|
||||||
|
#
|
||||||
|
# context-loom was missing this entirely in the 2026-09 audit; the other four
|
||||||
|
# had three subtly different versions.
|
||||||
|
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# YAML is indentation-significant and the rest of the ecosystem uses 2 spaces.
|
||||||
|
[*.{yaml,yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Docker/compose files follow the same convention.
|
||||||
|
[{Dockerfile,*.dockerfile}]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[{compose.yaml,compose.*.yaml,compose.yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Markdown: trailing whitespace is a hard line break in some renderers, so
|
||||||
|
# stripping it silently changes formatting.
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
# Generated / vendored content: never touch, even accidentally on save.
|
||||||
|
[{vendor/**,var/**,node_modules/**,public/bundles/**}]
|
||||||
|
insert_final_newline = false
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
# Caddy's own formatter (caddy fmt) indents with tabs, and every Caddyfile in
|
||||||
|
# the portfolio already uses them — at the root (preauth, penny-track,
|
||||||
|
# vital-pulse), under docker/ (the FrankenPHP app config), and under
|
||||||
|
# docs/examples/ (the edge-proxy reference copied from). Without this rule the
|
||||||
|
# `[*]` block above silently tells editors to use spaces, so every save
|
||||||
|
# reindents the file and caddy fmt immediately undoes it.
|
||||||
[Caddyfile]
|
[Caddyfile]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
[{compose.yaml,compose.*.yaml}]
|
[Makefile]
|
||||||
indent_size = 2
|
indent_style = tab
|
||||||
|
|
||||||
[*.md]
|
[*.{sh,bash}]
|
||||||
trim_trailing_whitespace = false
|
indent_size = 4
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
APP_ENV=test
|
||||||
|
APP_DEBUG=0
|
||||||
|
APP_SECRET=test_secret_key_change_me
|
||||||
|
# fixed TOTP secret (JBSWY3DPEHPK3PXP) so functional tests can compute valid codes
|
||||||
|
TOTP_URI='otpauth://totp/Test-TOTP?secret=JBSWY3DPEHPK3PXP'
|
||||||
|
COOKIE_TTL=2592000
|
||||||
|
SUBDOMAIN_REDIRECT=0
|
||||||
|
AUTH_SUBDOMAIN=''
|
||||||
|
IP_TTL=0
|
||||||
|
TEAPOT=1
|
||||||
|
BURST_COUNT=10
|
||||||
|
BURST_TIME=30
|
||||||
|
UPPER_COUNT=100
|
||||||
|
UPPER_TIME=3600
|
||||||
|
PUBLIC_PATHS=''
|
||||||
|
PUBLIC_BURST_COUNT=100
|
||||||
|
PUBLIC_BURST_TIME=60
|
||||||
|
PUBLIC_UPPER_COUNT=500
|
||||||
|
PUBLIC_UPPER_TIME=3600
|
||||||
|
TITLE='Pre-Authentication System'
|
||||||
|
BG_COLOR='#029386'
|
||||||
|
FG_COLOR='#ffffff'
|
||||||
|
ERROR_COLOR='#ffb16d'
|
||||||
|
ID_NAME='Session ID'
|
||||||
|
TOKEN_NAME='Authentication Token'
|
||||||
|
SUBMIT_NAME='Submit'
|
||||||
|
ERROR_MESSAGE='Unsuccessful login attempt'
|
||||||
|
TEAPOT_TITLE="I'm a teapot"
|
||||||
|
TEAPOT_MESSAGE='I refuse to brew coffee'
|
||||||
|
TOO_MANY_TITLE='Too many requests'
|
||||||
|
TOO_MANY_MESSAGE='Try again later'
|
||||||
|
SHELL_VERBOSITY=0
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Push Develop - update the "develop" rolling docker image tag, via the shared workflow
|
||||||
|
#
|
||||||
|
# The build is defined in docker-bake.hcl.
|
||||||
|
|
||||||
|
name: Push Develop
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
|
||||||
|
with:
|
||||||
|
mode: develop
|
||||||
|
|
||||||
|
# Passed explicitly from repo vars
|
||||||
|
image-target: ${{ vars.DOCKERHUB_TARGET }}
|
||||||
|
|
||||||
|
# docker-bake.hcl controls building
|
||||||
|
build-backend: 'bake'
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Push Docker - release new version to docker and update the "latest" rolling docker image tag, via the shared workflow
|
||||||
|
#
|
||||||
|
# The build is defined in docker-bake.hcl.
|
||||||
|
|
||||||
|
name: Push Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
|
||||||
|
with:
|
||||||
|
mode: release
|
||||||
|
|
||||||
|
# Passed explicitly from repo vars
|
||||||
|
image-target: ${{ vars.DOCKERHUB_TARGET }}
|
||||||
|
|
||||||
|
# docker-bake.hcl controls building
|
||||||
|
build-backend: 'bake'
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Sync GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
- 'feat*'
|
||||||
|
- 'fix*'
|
||||||
|
- 'cleanup*'
|
||||||
|
- 'chore*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config --global user.name "Andrew Sync"
|
||||||
|
git config --global user.email "sync@digitaladapt.com"
|
||||||
|
|
||||||
|
- name: Add GitHub Remote
|
||||||
|
env:
|
||||||
|
SYNC_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||||
|
SYNC_TARGET: ${{ vars.SYNC_GITHUB_TARGET }}
|
||||||
|
run: |
|
||||||
|
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET" 2>/dev/null || git remote set-url github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
|
||||||
|
|
||||||
|
- name: Push Current Branch
|
||||||
|
run: |
|
||||||
|
git push github HEAD:${GITHUB_REF_NAME}
|
||||||
|
|
||||||
|
- name: Push Tags
|
||||||
|
run: |
|
||||||
|
git push github --tags
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Tests - ensure code quality, via the shared workflow.
|
||||||
|
#
|
||||||
|
# Checks include: PHPStan, PHPUnit, and PHP-CS-Fixer.
|
||||||
|
#
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
- 'feat*'
|
||||||
|
- 'fix*'
|
||||||
|
- 'cleanup*'
|
||||||
|
- 'chore*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: private/ci/.gitea/workflows/php-test.yaml@v1
|
||||||
|
with:
|
||||||
|
php-version: '8.5'
|
||||||
|
|
||||||
|
# profiles defines what to test:
|
||||||
|
# * web-app: full test suite (default)
|
||||||
|
# * auth-gateway: skip template check
|
||||||
|
# * api-gateway: skip interface checks
|
||||||
|
profile: auth-gateway
|
||||||
|
|
||||||
|
# coverage defines how to check test-coverage:
|
||||||
|
# * pcov: recommended (default)
|
||||||
|
# * xdebug
|
||||||
|
coverage: 'pcov'
|
||||||
|
# 0-100 percentage of test-coverage required
|
||||||
|
coverage-min: '75'
|
||||||
|
|
||||||
|
# does failing our "conformance" check make the test suite fail
|
||||||
|
conformance-blocking: false
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
# github token so composer can download dependencies
|
||||||
|
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||||
+16
@@ -6,3 +6,19 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
|
||||||
|
###> phpunit/phpunit ###
|
||||||
|
/phpunit.xml
|
||||||
|
/.phpunit.cache/
|
||||||
|
/bin/.phpunit.result.cache
|
||||||
|
###< phpunit/phpunit ###
|
||||||
|
|
||||||
|
###> project-specific ###
|
||||||
|
/config/reference.php
|
||||||
|
###< project-specific ###
|
||||||
|
|
||||||
|
###> friendsofphp/php-cs-fixer ###
|
||||||
|
/.php-cs-fixer.php
|
||||||
|
/.php-cs-fixer.cache
|
||||||
|
###< friendsofphp/php-cs-fixer ###
|
||||||
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* .php-cs-fixer.dist.php — canonical shared config.
|
||||||
|
*
|
||||||
|
* Copy verbatim into a project root. LEAF file: sync = overwrite, never merge.
|
||||||
|
* Change it here and re-sync; do not hand-edit per repo (GUIDING-LIGHT §8.2).
|
||||||
|
*
|
||||||
|
* This replaces three divergent versions found in the 2026-09 audit:
|
||||||
|
* context-loom — had @Symfony + risky + declare_strict_types
|
||||||
|
* penny-track / preauth / vital-pulse — a second variant
|
||||||
|
* task-weaver — a third variant
|
||||||
|
*
|
||||||
|
* Pin friendsofphp/php-cs-fixer to ^3.95 in composer.json. preauth was on
|
||||||
|
* "*", which means its CI was not reproducible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$config = new PhpCsFixer\Config();
|
||||||
|
|
||||||
|
return $config
|
||||||
|
->setRiskyAllowed(true)
|
||||||
|
->setRules([
|
||||||
|
'@Symfony' => true,
|
||||||
|
'@Symfony:risky' => true,
|
||||||
|
|
||||||
|
// Unambiguous wins.
|
||||||
|
'declare_strict_types' => true,
|
||||||
|
'no_unused_imports' => true,
|
||||||
|
'ordered_imports' => [
|
||||||
|
'sort_algorithm' => 'alpha',
|
||||||
|
'imports_order' => ['class', 'function', 'const'],
|
||||||
|
],
|
||||||
|
'php_unit_method_casing' => ['case' => 'snake_case'],
|
||||||
|
|
||||||
|
// Trailing commas in multiline constructs keep diffs to one line when
|
||||||
|
// a parameter is appended — reviewable, and no reformat noise.
|
||||||
|
'trailing_comma_in_multiline' => [
|
||||||
|
'elements' => ['arrays', 'arguments', 'parameters', 'match'],
|
||||||
|
],
|
||||||
|
|
||||||
|
// `array()` → `[]`, consistent with everything else in these repos.
|
||||||
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
|
|
||||||
|
// Group imports so a file's dependency surface is scannable.
|
||||||
|
'global_namespace_import' => [
|
||||||
|
'import_classes' => true,
|
||||||
|
'import_constants' => false,
|
||||||
|
'import_functions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Keep `#[Attribute]`-style attributes on their own line for long ones.
|
||||||
|
'attribute_empty_parentheses' => true,
|
||||||
|
])
|
||||||
|
->setFinder(
|
||||||
|
(new PhpCsFixer\Finder())
|
||||||
|
->in(__DIR__)
|
||||||
|
->exclude('vendor')
|
||||||
|
->exclude('var')
|
||||||
|
->exclude('node_modules')
|
||||||
|
// Migration classes are generated and version-stamped upstream;
|
||||||
|
// reformatting them makes diffs against the generator noisy.
|
||||||
|
->notPath('src/Migrations')
|
||||||
|
// Symfony's config reference is regenerated by `cache:clear`, which
|
||||||
|
// composer runs on every install — so it is present in CI even
|
||||||
|
// though it is gitignored. Formatting it makes the fixer report a
|
||||||
|
// file the author cannot commit, and a fresh `cache:clear`
|
||||||
|
// immediately undoes the fix, so CI can never go green.
|
||||||
|
->notPath('config/reference.php')
|
||||||
|
->ignoreDotFiles(true)
|
||||||
|
->ignoreVCS(true)
|
||||||
|
);
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased] — v1.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Public rate-limited access** — Select paths can now be made publicly
|
||||||
|
accessible without TOTP authentication, with separate per-IP rate limiting.
|
||||||
|
This is useful for exposing public content (e.g., public Gitea repositories)
|
||||||
|
while protecting server resources from bot traffic.
|
||||||
|
- New `PUBLIC_PATHS` env var: comma-separated path patterns with `*` (single
|
||||||
|
segment) and `**` (cross-segment) wildcard support. Optional host prefix
|
||||||
|
(e.g., `code.example.com/public/**`). When empty (default), the feature
|
||||||
|
is fully disabled.
|
||||||
|
- New `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` env vars for burst rate
|
||||||
|
limiting (default: 100 requests per 60 seconds).
|
||||||
|
- New `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` env vars for sustained
|
||||||
|
rate limiting (default: 500 requests per 3600 seconds).
|
||||||
|
- Authenticated users bypass the public rate limiter entirely.
|
||||||
|
- Over-limit responses include a `Retry-After` header.
|
||||||
|
- New `PublicPathMatcher` service for path pattern matching.
|
||||||
|
- New `PublicAccessListener` (priority 84) in the request pipeline.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to
|
||||||
|
`8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean
|
||||||
|
(test suite runs with `failOnDeprecation`), so the major-version jump
|
||||||
|
required no application code changes. See
|
||||||
|
`docs/symfony-8.1-upgrade-plan.md`.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **`runtime/frankenphp-symfony`** — No longer needed: `symfony/runtime`
|
||||||
|
8.1 handles FrankenPHP worker mode natively via its built-in
|
||||||
|
`FrankenPhpWorkerRunner`. The `extra.runtime` override in
|
||||||
|
`composer.json` was removed so the runtime auto-detects FrankenPHP.
|
||||||
|
The old package's `FRANKENPHP_LOOP_MAX` env var is no longer read;
|
||||||
|
an equivalent recycle limit is restored via the new `MAX_REQUESTS`
|
||||||
|
setting below.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`MAX_REQUESTS` worker-thread recycle limit** — The `Caddyfile` now
|
||||||
|
sets FrankenPHP's native `max_requests` from the `MAX_REQUESTS`
|
||||||
|
environment variable: each PHP worker thread is gracefully restarted
|
||||||
|
after N requests while others keep serving, containing slow memory
|
||||||
|
growth across long uptime. The image default is **500** (matching the
|
||||||
|
previous `runtime/frankenphp-symfony` default), baked in as a Docker
|
||||||
|
build arg and overridable at runtime (`MAX_REQUESTS=0` disables
|
||||||
|
restarts). Arbitrary `frankenphp`-block configuration is still
|
||||||
|
possible via the stock `FRANKENPHP_CONFIG` env var.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Login flow responses are no longer cacheable** — the login page,
|
||||||
|
failed logins, redirects, and rate-limit/error pages now send strict
|
||||||
|
anti-caching headers (`Cache-Control: no-store, no-cache,
|
||||||
|
must-revalidate, proxy-revalidate, max-age=0, s-maxage=0` plus
|
||||||
|
`Pragma`, `Expires`, `Surrogate-Control`, and `Vary: *`), the login
|
||||||
|
form's `fetch()` bypasses the HTTP cache, and the example Caddyfile
|
||||||
|
guards every `forward_auth` block with matching `header_down` rules.
|
||||||
|
This prevents browsers — notably older Safari — from replaying a stale
|
||||||
|
pre-auth response on refresh (previously: log in successfully, refresh,
|
||||||
|
and land back on the login page). Successful (2xx) responses are
|
||||||
|
deliberately excluded: they are consumed by the proxy's `forward_auth`
|
||||||
|
check and never reach the browser.
|
||||||
|
|
||||||
|
## [1.0.0] — v1.0 Release
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
|
||||||
|
variable with four modes: `session` (default), `static`, `mapped`, and `none`.
|
||||||
|
This allows deployments to prevent user-controlled header values from reaching
|
||||||
|
backend services.
|
||||||
|
- Added `SecurityHeadersListener` to set `X-Content-Type-Options`, `X-Frame-Options`,
|
||||||
|
`Content-Security-Policy`, `Referrer-Policy`, and `Strict-Transport-Security`
|
||||||
|
headers on all responses.
|
||||||
|
- Replaced `document.write()` with `document.documentElement.innerHTML` in login
|
||||||
|
page JavaScript to avoid CSP violations.
|
||||||
|
- Added CSS escaping (`|e('css')`) to environment-configured color values in
|
||||||
|
the login page template to prevent CSS injection.
|
||||||
|
- Documented CSRF protection model: the nonce system provides CSRF protection
|
||||||
|
for POST form logins (server-generated, single-use, 120s TTL).
|
||||||
|
- Reduced TOTP verification window from 10 periods (±5 minutes) to 1 period
|
||||||
|
(±30 seconds) to reduce brute-force attack surface.
|
||||||
|
- Removed hardcoded `APP_SECRET` from `bin/franken.sh` (now uses environment
|
||||||
|
variable or generates a random secret).
|
||||||
|
- Removed backup code values from debug log output.
|
||||||
|
- Added `.env` to `.gitignore`.
|
||||||
|
- Expanded TLD list in `DomainManager` with many missing multi-part TLDs
|
||||||
|
(`.com.au`, `.co.jp`, `.com.br`, `.co.kr`, `.com.tw`, `.co.za`, etc.)
|
||||||
|
to prevent open redirect vulnerabilities from incorrect domain matching.
|
||||||
|
- Lowercased host before TLD lookup to fix case-sensitivity issue.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed `$payload->json` access on possibly-null `$payload` in `LoginListener`
|
||||||
|
using null-safe operator (`?->`).
|
||||||
|
- Fixed `validReturn()` not checking `false` return from `parse_url()`, which
|
||||||
|
could cause a `TypeError` on malformed URLs.
|
||||||
|
- Added `isHit()` race condition check in `AcceptListener` and `AllowListener`
|
||||||
|
between `hasItem()` and `getItem()` calls.
|
||||||
|
- Added `try/finally` in `Kernel::terminate()` so `parent::terminate()` always
|
||||||
|
runs even if `persist()` throws an exception.
|
||||||
|
- Added input validation to `GenerateBackupCodesCommand` — rejects count < 1.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Disabled unused Symfony sessions in `framework.yaml` (preauth implements its
|
||||||
|
own cookie/cache-based session management).
|
||||||
|
- Standardized git tag format to use `v` prefix (`v1.0.0` instead of `1.0.0`).
|
||||||
|
- Updated CI workflows to use `v*.*.*` tag pattern and strip `v` prefix for
|
||||||
|
Docker image tags.
|
||||||
|
- Removed stale `develop` branch from CI triggers.
|
||||||
|
- Fixed `publish.yaml` to use `git remote set-url` on re-runs instead of
|
||||||
|
failing when the remote already exists.
|
||||||
|
- Explicitly install `curl` in the Docker final image (needed for healthcheck).
|
||||||
|
- Added `declare(strict_types=1)` to all interface files.
|
||||||
|
- Added `#[AsCommand]` attribute to `GenerateBackupCodesCommand`.
|
||||||
|
- Fixed `BackupCodeInterface` default count to match implementation (10).
|
||||||
|
- Used `Response::HTTP_INTERNAL_SERVER_ERROR` constant in `GetTotpTrait`
|
||||||
|
instead of literal `500`.
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- PHP-CS-Fixer with PSR-12 configuration and CI check.
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-07-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- PHPUnit test suite — 222 tests, 100% code coverage (lines, methods, classes).
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-05-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bug fixes and cleanup from develop branch merge.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Renamed form fields for clarity.
|
||||||
|
- Fixed invalid login bug.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Single-use backup codes via `app:generate-backup-codes` console command.
|
||||||
|
- Cache persistence improvement — only write changed keys to file storage.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Static password and lookup token (security risks).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated to PHP 8.5, updated dependencies.
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-02-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Optional (disabled by default) ability to lookup token by static password.
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-01-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Optional (disabled by default) ability to use a static password as backup auth.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Nonce-related cleanup.
|
||||||
|
|
||||||
|
## [0.4.1] - 2025-12-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bug which can occur if cache files are deleted.
|
||||||
|
|
||||||
|
## [0.4.0] - 2025-12-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Massive rewrite to listener-based architecture instead of controllers.
|
||||||
|
- Login payload sent via `X-Preauth` header instead of GET request parameters.
|
||||||
|
- Enhanced cookie security.
|
||||||
|
- Removed icon system and asset system.
|
||||||
|
|
||||||
|
## [0.3.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Breaking:** Default port and transport changed to HTTP on port 80.
|
||||||
|
- **Breaking:** Environment variable names have changed.
|
||||||
|
- Refactored to Symfony 7.4 with FrankenPHP.
|
||||||
|
|
||||||
|
## [0.2.0] - 2025-12-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Login rate limiting (burst + upper window).
|
||||||
|
- Error page for rate-limited clients ("too many requests").
|
||||||
|
- Example Docker Compose file.
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-11-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker image published to Docker Hub.
|
||||||
|
- PHP-FPM based, code in `src/`, templates in separate files.
|
||||||
|
|
||||||
|
## [0.0.1] - 2024-06-26
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Started as a single-file script in Caddy config. Hardcoded TOTP secret,
|
||||||
|
zero flexibility, but functional. Ran quietly in production for about a
|
||||||
|
year before any real development began.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Contributing to Preauth
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to Preauth! This document
|
||||||
|
outlines the process for contributing to the project.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Install dependencies: `composer install`
|
||||||
|
3. Copy `.env.example` to `.env` and configure as needed
|
||||||
|
4. Run tests: `vendor/bin/phpunit`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and
|
||||||
|
includes `php-cs-fixer` as a dev dependency.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for style violations
|
||||||
|
vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||||
|
|
||||||
|
# Auto-fix
|
||||||
|
vendor/bin/php-cs-fixer fix
|
||||||
|
```
|
||||||
|
|
||||||
|
All code must pass the style check before it can be merged.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All code changes must include tests. The project maintains 100% code
|
||||||
|
coverage — new code must be fully tested.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
vendor/bin/phpunit
|
||||||
|
|
||||||
|
# Run with coverage (requires Xdebug)
|
||||||
|
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- **Unit tests** go in `tests/Unit/` and mirror the `src/` directory structure
|
||||||
|
- **Functional tests** go in `tests/Functional/` and test the full HTTP kernel
|
||||||
|
- Use the support traits (`TotpTestHelper`, `ListenerTestHelper`) for
|
||||||
|
reusable test fixtures
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. Create a feature branch from `main`
|
||||||
|
2. Make your changes, ensuring tests pass and code style is clean
|
||||||
|
3. Update documentation if needed (README, CHANGELOG, docs/)
|
||||||
|
4. Submit a pull request to `main`
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
|
||||||
|
Use conventional commit format:
|
||||||
|
|
||||||
|
- `feat:` new feature
|
||||||
|
- `fix:` bug fix
|
||||||
|
- `docs:` documentation only
|
||||||
|
- `refactor:` code change that neither fixes a bug nor adds a feature
|
||||||
|
- `test:` adding or correcting tests
|
||||||
|
- `chore:` build process, tooling, etc.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Preauth is an event-listener-driven Symfony application (no controllers).
|
||||||
|
See `ROADMAP.md` for the full architecture overview and design decisions.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under
|
||||||
|
the MIT License.
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
|
{
|
||||||
|
frankenphp {
|
||||||
|
# Restart each PHP worker thread after this many requests, containing
|
||||||
|
# slow memory growth across long uptime. Preserves the 7.4-era default
|
||||||
|
# loop count of runtime/frankenphp-symfony (500) after the Symfony 8.1
|
||||||
|
# upgrade. Set MAX_REQUESTS=0 to disable restarts. The Dockerfile bakes
|
||||||
|
# in the default of 500 via build arg; override at runtime with:
|
||||||
|
# docker run -e MAX_REQUESTS=5000 ...
|
||||||
|
# For full control, the stock FRANKENPHP_CONFIG env var can inject any
|
||||||
|
# directive under this block instead.
|
||||||
|
max_requests {$MAX_REQUESTS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
http://
|
http://
|
||||||
root public/
|
root public/
|
||||||
rewrite index.php
|
rewrite index.php
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# Design Considerations — Preauth
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Preauth is a well-architected TOTP-based authentication gateway that has evolved from a single-file script into a clean, event-listener-driven Symfony application with 100% test coverage. The codebase demonstrates strong security fundamentals (host-prefixed cookies, nonce-based replay protection, rate limiting, backup code system) and thoughtful operational design (dual-layer cache with change tracking, FrankenPHP worker mode).
|
||||||
|
|
||||||
|
This document was originally prepared as a design review. Items that have been addressed are marked with ✅ and include a reference to the commit or change that resolved them. Items still open are marked with ⬜ and remain as recommendations for future work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Security
|
||||||
|
|
||||||
|
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. A `SecurityHeadersListener` (response event, priority 0) now sets the following headers on all main-request responses:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Strict-Transport-Security: max-age=31536000
|
||||||
|
```
|
||||||
|
|
||||||
|
The inline `<script>` and `<style>` in the templates mean a CSP with `'unsafe-inline'` for `script-src` and `style-src` is the strictest practical policy today. Moving scripts/styles to external files would allow a stricter CSP in the future.
|
||||||
|
|
||||||
|
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The `Remote-User` header value is now configurable via the `REMOTE_USER` environment variable, which supports four modes:
|
||||||
|
|
||||||
|
- **`session`** (default, backward-compatible): Sends the session id, as before. The value is still sanitized via `makeCacheKey()`.
|
||||||
|
- **`static`**: Sends a fixed string (configurable via `REMOTE_USER_STATIC`, default `authenticated`) for all authenticated requests. This eliminates the user-controlled header issue entirely.
|
||||||
|
- **`mapped`**: Looks up the session id in a configured map (`REMOTE_USER_MAP`, format: `id1:user1,id2:user2`) and sends the mapped value. Falls back to the session id if not found in the map. This is the path to multi-user support.
|
||||||
|
- **`none`**: Omits the `Remote-User` header entirely. Caddy's `forward_auth` still accepts the request based on the 200 status code.
|
||||||
|
|
||||||
|
The `RemoteUserMode` enum (`src/Enum/RemoteUserMode.php`) encapsulates the modes. `StringTrait::authSuccessResponse()` resolves the header value based on the configured mode, and `ConfigBag` handles parsing the map string and validating the mode (invalid values fall back to `session`). `AcceptListener` now receives `ConfigBag` as a constructor dependency to support this.
|
||||||
|
|
||||||
|
### 1.3 No CSRF Protection on POST Form Login [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Resolved through documentation and analysis. The nonce system provides CSRF protection for the POST form path: nonces are server-generated, single-use, and have a 120-second TTL. An attacker cannot forge a POST request without first loading the login page to obtain a valid nonce, which requires being on the auth subdomain. The `LoginListener` class docblock and `login.html.twig` template comment now explicitly document this CSRF protection model. The AJAX (header) path embeds the nonce in the base64url payload.
|
||||||
|
|
||||||
|
### 1.4 TOTP Verification Leeway May Be Too Generous [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The TOTP verification window has been reduced from 10 periods (±5 minutes) to 1 period (±30 seconds). With the default 30-second TOTP period, a code is now valid for at most 90 seconds (the current window plus one window on each side), down from the previous 50 seconds per window with 10-period leeway. The ROADMAP has been updated to reflect this change.
|
||||||
|
|
||||||
|
### 1.5 Backup Code Logging Reveals Code Value [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The debug log in `BackupCodeManager::verifyAndConsume()` no longer includes the backup key name. It now logs only the hit/miss and valid/invalid status: `"checking backup code: HIT & VALID"` or `"checking backup code: miss & invalid"`.
|
||||||
|
|
||||||
|
### 1.6 TOTP Object Reconstructed on Every Verification [LOW PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** `GetTotpTrait::getTotp()` calls `OTHP\Factory::loadFromProvisioningUri()` on every invocation. This parses the OTP URI string and constructs a new TOTP object each time a token is verified.
|
||||||
|
|
||||||
|
**Note:** An attempt was made to memoize the TOTP object within the request cycle, but PHP 8.4's `readonly` class constraint prevents traits from defining mutable properties in `readonly` classes (`LoginManager` and `BackupCodeManager` are both `final readonly`). Resolving this would require either removing `readonly` from these classes, using a separate memoization service, or refactoring `GetTotpTrait` into a dedicated injectable service.
|
||||||
|
|
||||||
|
**Why:** This is a minor performance concern — URI parsing and TOTP object construction happen on every login attempt. In a FrankenPHP worker process that handles many requests, this adds unnecessary overhead. It's not a security issue, but it's an easy optimization if the readonly constraint is relaxed.
|
||||||
|
|
||||||
|
### 1.7 CSS Injection in Style Template [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. Environment-configured color values (`bg_color`, `fg_color`, `error_color`) in `_style.html.twig` are now escaped with Twig's `|e('css')` filter to prevent CSS injection from malicious environment variable values.
|
||||||
|
|
||||||
|
### 1.8 $payload->json Access on Possibly-Null Payload [HIGH PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. In `LoginListener::onKernelRequest()`, the `$payload->json` access on a possibly-null `$payload` has been replaced with `$payload?->json ?? true`, and `$payload->id` with `$payload?->id ?? ''`. This prevents a crash when a login attempt is detected (e.g., via the `X-Preauth` header) but the payload is invalid (malformed base64, non-object JSON, etc.).
|
||||||
|
|
||||||
|
### 1.9 validReturn() Doesn't Check false from parse_url [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. `DomainManager::validReturn()` now checks for `false` and empty string in addition to `null` when examining the return value of `parse_url($url, PHP_URL_HOST)`. This prevents a `TypeError` on malformed URLs that `filter_var(FILTER_VALIDATE_URL)` accepts but `parse_url` cannot parse.
|
||||||
|
|
||||||
|
### 1.10 Incomplete TLD List in DomainManager [HIGH PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The TLD lookup table in `DomainManager` has been significantly expanded with many previously missing multi-part TLDs, including `.com.au`, `.co.jp`, `.com.br`, `.co.kr`, `.com.tw`, `.co.za`, and dozens more. Without these entries, domains like `evil.com.au` would incorrectly match `auth.example.com.au` (both would resolve to base `com.au`), creating an open redirect vulnerability. The host is also now lowercased before TLD lookup to fix a case-sensitivity issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture & Code Quality
|
||||||
|
|
||||||
|
### 2.1 Trait-Based Dependency Injection Pattern [MEDIUM PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** Several traits (`HasLoggerTrait`, `GetTotpTrait`, `MakeNonceTrait`) use `#[Required]` attribute for setter injection into `readonly` classes. For example, `LoginManager` receives `$config`, `$logger`, and `$nonceCache` via traits rather than through its constructor. The constructor only accepts three parameters; the rest are wired via setter methods called by the service container after construction.
|
||||||
|
|
||||||
|
**Recommendation:** Move these dependencies into the constructors of the classes that use them. If multiple classes share the same dependencies, that's fine — PHP constructors can accept many parameters, and it makes the dependency graph explicit. Alternatively, create a shared `Dependencies` value object that bundles logger, config, and nonce cache.
|
||||||
|
|
||||||
|
**Why:** The trait-based setter injection pattern makes it non-obvious what dependencies a class has — you have to look at both the constructor and all the traits it uses. It also creates a temporal coupling issue: the object exists in a partially-constructed state between construction and setter calls. With `readonly` classes, this works only because the trait properties are declared in the trait, not the class, which is a subtle language detail that could confuse future maintainers. Standard constructor injection is more explicit, testable, and conventional in Symfony.
|
||||||
|
|
||||||
|
### 2.2 Duplicated Cookie Logic Between LoginManager and InterceptListener [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Resolved. The duplicated cookie name and domain selection logic has been extracted into two shared methods on `CookieNameTrait`:
|
||||||
|
|
||||||
|
- `sessionCookieName(DomainInterface $domainManager): string` — Returns the appropriate cookie name (`__Host-Http-Preauth` or `__Http-Domain-Preauth`) based on whether central auth is active.
|
||||||
|
- `sessionCookieDomain(DomainInterface $domainManager, string $host): ?string` — Returns the cookie domain for central auth mode, or null for single-domain mode.
|
||||||
|
|
||||||
|
`LoginManager::setCookie()`, `AcceptListener::onKernelRequest()`, and `InterceptListener::pruneInvalidCookie()` all now use these shared methods. The fragile "changes here must be reflected in InterceptListener::pruneInvalidCookie()" comment has been removed.
|
||||||
|
|
||||||
|
### 2.3 MonitorCacheKeys Instantiated Multiple Times for Same Pool [MEDIUM PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** `MonitorCacheKeys` is a decorator that tracks cache key changes. It's instantiated independently in `PersistCache`, `LoginManager`, and `BackupCodeManager`, each wrapping the same underlying `CacheItemPoolInterface`. The key list (`__key_list`) and change list (`__chg_list`) are stored in the cache itself, so the instances share state — but each instance calls `initialize()` in its constructor if the lists don't exist yet, and each `save()`/`saveDeferred()` call triggers additional metadata writes.
|
||||||
|
|
||||||
|
**Recommendation:** Register `MonitorCacheKeys` as a decorated service in the DI container (using Symfony's `decorates` feature) so there's a single instance per cache pool. Or, make `MonitorCacheKeys` a stateless service that's injected once, rather than having each consumer create its own wrapper.
|
||||||
|
|
||||||
|
**Why:** Multiple instances wrapping the same pool is wasteful — each `save()` call triggers a cascade of metadata operations (update key list, log change, commit). With three instances, a single cache write could trigger nine additional cache operations. A single decorator service would be more efficient and would make the lifecycle clearer.
|
||||||
|
|
||||||
|
### 2.4 Payload Base64url Decoding Has Broken Padding [MEDIUM PRIORITY] ✅ Already Correct
|
||||||
|
|
||||||
|
**Current state:** Not an issue. The code correctly uses:
|
||||||
|
```php
|
||||||
|
$base64 = strtr($base64url, '-_', '+/');
|
||||||
|
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
||||||
|
```
|
||||||
|
This was fixed in a prior commit ("Fix docs, add .dockerignore, fix base64url padding, fix typo"). The original review incorrectly reported the use of `str_pad`; the implementation now correctly uses `str_repeat` to add the proper number of `=` padding characters.
|
||||||
|
|
||||||
|
### 2.5 Symfony Sessions Enabled But Unused [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. `config/packages/framework.yaml` now has `session: false` with a comment explaining that preauth implements its own cookie/cache-based session management and does not use Symfony's session subsystem.
|
||||||
|
|
||||||
|
### 2.6 config/reference.php Committed to Repository [LOW PRIORITY] ✅ Already Handled
|
||||||
|
|
||||||
|
**Current state:** Not an issue. `config/reference.php` is already listed in `.gitignore` under the project-specific section and is not tracked in version control.
|
||||||
|
|
||||||
|
### 2.7 Public Properties on Payload DTO [LOW PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** `Payload` uses public properties (`$id`, `$token`, `$nonce`, `$json`, `$scope`) with no encapsulation. The object is mutable after construction.
|
||||||
|
|
||||||
|
**Recommendation:** Consider making `Payload` a `readonly` class (PHP 8.4+ supports `readonly` classes natively) with a constructor that takes all fields, or use Symfony's `Stringable`/value object patterns. Since `LoginManager` mutates `$payload->scope` (downgrading IP to Cookie), the current design requires mutability — but this could be handled by returning a new instance instead.
|
||||||
|
|
||||||
|
**Why:** Immutable DTOs are safer to pass around, especially in an event-driven system where the same object might be referenced by multiple listeners. The current mutation in `LoginManager::checkToken()` (changing `$payload->scope`) is a side effect that's not obvious from the method signature.
|
||||||
|
|
||||||
|
### 2.8 Duplicated "hi $id" Response Construction [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The duplicated `new Response("hi $id", headers: ['Content-Type' => 'text/plain', 'Remote-User' => $id])` pattern in `AcceptListener`, `AllowListener`, and `LoginManager` has been extracted into `StringTrait::authSuccessResponse(string $id): Response`, which all three classes now use.
|
||||||
|
|
||||||
|
### 2.9 Duplicated Constants [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The duplicated `'2999-12-31'` far-future date string (previously in `Utilities::makeTotp()` and `BackupCodeManager::verifyAndConsume()`/`saveCodes()`) and the `128` max input length (previously in `StringTrait::makeCacheKey()` and `Payload::create()`) have been extracted into `AppConstants::FAR_FUTURE_DATE` and `AppConstants::MAX_INPUT_LENGTH` respectively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Testing
|
||||||
|
|
||||||
|
### 3.1 No Tests for Concurrent Access / Race Conditions [LOW PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** The test suite is excellent — 222 tests, 100% coverage, good edge case coverage. However, there are no tests for concurrent access scenarios, such as two requests using the same nonce simultaneously, or cache initialization race conditions in `MonitorCacheKeys`.
|
||||||
|
|
||||||
|
**Recommendation:** Add a few integration tests that simulate concurrent access (e.g., using process forks or mock caches with delays). At minimum, document that concurrent access is expected to be handled by APCu's atomic operations.
|
||||||
|
|
||||||
|
**Why:** `MonitorCacheKeys::initialize()` checks if key lists exist and creates them if not — under concurrent startup, two instances could both see missing lists and both call `initialize()`. This is likely fine because APCu operations are atomic, but it's worth having a test or at least a documented assumption. The race condition between `hasItem()` and `getItem()` in `AcceptListener` and `AllowListener` is now handled with an `isHit()` check, but is not tested.
|
||||||
|
|
||||||
|
### 3.2 No Security-Focused Test Suite [LOW PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** Security behaviors (nonce replay, backup code reuse, rate limiting) are tested as part of the functional and unit tests, but there's no dedicated security test suite that systematically probes for common vulnerabilities.
|
||||||
|
|
||||||
|
**Recommendation:** Consider adding a `tests/Security/` directory with tests for: XSS attempts in the username field, header injection via the `return` parameter, cookie attribute verification (Secure, HttpOnly, SameSite), and response header presence (now that security headers are added).
|
||||||
|
|
||||||
|
**Why:** For an authentication gateway, security testing deserves its own focused suite that's easy to find and extend. This also makes it easier for security reviewers to understand what's been tested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Docker & Deployment
|
||||||
|
|
||||||
|
### 4.1 Healthcheck Depends on curl Which May Not Be Installed [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The Dockerfile now explicitly installs `curl` in the final image with `apt-get install -y --no-install-recommends curl` and cleans up the apt lists to keep the image small.
|
||||||
|
|
||||||
|
### 4.2 Typo in bin/franken.sh [LOW PRIORITY] ✅ Already Fixed / Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The typo (`digtialadapt` → `digitaladapt`) was corrected in a prior commit. The script has since been further improved: the hardcoded `APP_SECRET` has been removed (now uses the `APP_SECRET` environment variable or generates a random secret), and the `docker container rm` command now suppresses errors when the container doesn't exist.
|
||||||
|
|
||||||
|
### 4.3 No .dockerignore File [LOW PRIORITY] ✅ Already Handled
|
||||||
|
|
||||||
|
**Current state:** Not an issue. A `.dockerignore` file exists and excludes `.git/`, `.gitignore`, `var/`, `vendor/`, `tests/`, `.phpunit.cache/`, `docs/`, `*.md`, `.env`, `.env.test`, `.env.local`, and `composer.phar` from the Docker build context. This was added in a prior commit.
|
||||||
|
|
||||||
|
### 4.4 Dockerfile Uses PHP 8.5 Which Is Bleeding Edge [LOW PRIORITY] ⬜ Open (Deliberate)
|
||||||
|
|
||||||
|
**Current state:** The Dockerfile uses `php:8.5-trixie` for the build stage and `dunglas/frankenphp:php8.5-trixie` for the final image. `composer.json` requires `php >= 8.4`. The CI workflow in `tests.yaml` also uses PHP 8.5.
|
||||||
|
|
||||||
|
**Recommendation:** This is a deliberate choice and likely fine for a personal project. If broader compatibility is desired, consider testing against both PHP 8.4 and 8.5 in CI. The `composer.json` already allows 8.4+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
### 5.1 Cache Exceptions Propagate as 500 Errors [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. Cache operations in `AcceptListener::onKernelRequest()` and `AllowListener::onKernelRequest()` are now wrapped in try/catch blocks that catch `Psr\Cache\InvalidArgumentException`. On a cache error, the listener logs the error at `error` level and returns without setting a response — causing the request to fall through to the next listener, which will eventually present the login page. This is a "fail closed" approach: if the cache is unavailable, the user is not authenticated.
|
||||||
|
|
||||||
|
**Note:** `LoginManager::checkToken()` and `BackupCodeManager::verifyAndConsume()` still declare `@throws InvalidArgumentException`. These are called from `LoginListener`, which does not catch the exception. A cache failure during login verification would still result in a 500 error. This is a lower-priority concern since login failures already result in a 401 response path.
|
||||||
|
|
||||||
|
### 5.2 No Global Exception Handling for Auth Flow [LOW PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** There is no `ExceptionListener` or `ErrorController` configured. Symfony's default error handling will produce a generic error page for uncaught exceptions. In dev mode (`APP_DEBUG=1`), this shows a full stack trace.
|
||||||
|
|
||||||
|
**Recommendation:** Add a simple exception listener that catches exceptions from the auth flow and returns a clean 401 or 503 response with the login page or error template. Alternatively, configure `framework.error_controller` to use a custom controller that renders the error template.
|
||||||
|
|
||||||
|
**Why:** For an auth gateway, every response should be intentional. A raw Symfony error page (even in production mode) doesn't match the styled login/error pages and could leak information about the internal architecture.
|
||||||
|
|
||||||
|
### 5.3 Kernel::terminate() Not Using try/finally [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. `Kernel::terminate()` now wraps `$this->persistCache->persist()` in a `try` block with a `finally` block that calls `parent::terminate()`. This ensures that the Symfony kernel termination always runs, even if the cache persistence throws an exception.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend
|
||||||
|
|
||||||
|
### 6.1 document.write() in Login Script [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The `document.open(); document.write(html); document.close();` pattern in `_script.html.twig` has been replaced with `document.documentElement.innerHTML = html;`. This avoids the deprecated `document.write()` call and is compatible with the Content-Security-Policy now set by `SecurityHeadersListener`.
|
||||||
|
|
||||||
|
### 6.2 No Input Sanitization in Username Echo [LOW PRIORITY] ⬜ Open
|
||||||
|
|
||||||
|
**Current state:** In `login.html.twig`, the username is echoed back into the input value: `value="{{ username }}"`. The username comes from the sanitized `makeCacheKey()` output, which restricts to `[A-Za-z0-9_.]`, so HTML injection is not possible with the current sanitization. Twig's auto-escaping is also on by default.
|
||||||
|
|
||||||
|
**Recommendation:** Add Twig's `escape` filter explicitly for defense-in-depth: `value="{{ username|e('html_attr') }}"`. Also consider whether the `message` variable in `<p id="preauth-message">{{ message|default }}</p>` could ever contain user input.
|
||||||
|
|
||||||
|
**Why:** While the current sanitization prevents XSS, relying on `makeCacheKey()` for HTML safety is an implicit coupling between cache key logic and output safety. If `makeCacheKey()` were ever relaxed to allow more characters, the template would become vulnerable. Twig auto-escaping handles HTML body context, but `html_attr` escaping is more appropriate for attribute contexts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Configuration
|
||||||
|
|
||||||
|
### 7.1 No Validation of Environment Variables [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. Environment variables in `config/services.yaml` now use Symfony's env var processors for type casting:
|
||||||
|
|
||||||
|
- `app.cookie_ttl: '%env(int:COOKIE_TTL)%'`
|
||||||
|
- `app.subdomain_redirect: '%env(bool:SUBDOMAIN_REDIRECT)%'`
|
||||||
|
- `app.ip_ttl: '%env(int:IP_TTL)%'`
|
||||||
|
- `app.teapot: '%env(bool:TEAPOT)%'`
|
||||||
|
|
||||||
|
This ensures invalid values fail fast at container compilation rather than at runtime with a confusing type error. The `rate_limiter.yaml` already used `%env(int:...)%` — this pattern is now applied consistently.
|
||||||
|
|
||||||
|
### 7.2 APP_SECRET Not Used Meaningfully [LOW PRIORITY] ✅ Addressed (Documented)
|
||||||
|
|
||||||
|
**Current state:** `APP_SECRET` is configured in `framework.yaml` and is required by Symfony. Preauth doesn't use Symfony sessions (now explicitly disabled), CSRF tokens, or signed cookies — the main uses of `APP_SECRET`. The README now documents that `APP_SECRET` is a Symfony requirement and that session cookies are random ULIDs looked up in cache, not signed tokens. The hardcoded `APP_SECRET` in `bin/franken.sh` has also been removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Documentation
|
||||||
|
|
||||||
|
### 8.1 Missing Security Model Documentation [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Addressed. The README now includes a comprehensive "Security Model" section under "Architecture" that covers:
|
||||||
|
|
||||||
|
- Cookie security attributes (`__Host-` prefix, `SameSite=Strict`, `Secure`, `HttpOnly`)
|
||||||
|
- Nonce system (15-byte random, single-use, 120s TTL)
|
||||||
|
- TOTP verification window (±1 period / ±30 seconds)
|
||||||
|
- Backup codes (case-insensitive, single-use, alphanumeric)
|
||||||
|
- Rate limiting (per-IP, compound sliding window, cannot be disabled)
|
||||||
|
- Security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS)
|
||||||
|
|
||||||
|
A dedicated `docs/SECURITY.md` with the full threat model and `Remote-User` guidance (see item 1.2) could still be valuable as a standalone document.
|
||||||
|
|
||||||
|
### 8.2 Missing CHANGELOG.md [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. A `CHANGELOG.md` has been created following the [Keep a Changelog](https://keepachangelog.com/) format, with full version history from v0.0.1 through the unreleased v1.0 changes. The version history was previously inline in the README.
|
||||||
|
|
||||||
|
### 8.3 Missing CONTRIBUTING.md [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. A `CONTRIBUTING.md` has been created with development setup instructions, code style guidelines, testing requirements, PR process, commit message conventions, and architecture overview.
|
||||||
|
|
||||||
|
### 8.4 Stale Branch References in ROADMAP [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The ROADMAP's branch status table has been updated to reflect that all feature branches have been pruned and development uses a feature-branch + PR workflow into `main`. Completed security review items are now checked off, and the TOTP leeway description has been updated from "10-second leeway" to "±1 period leeway (±30 seconds)".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. CI & Workflows
|
||||||
|
|
||||||
|
### 9.1 Inconsistent Tag Format [MEDIUM PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. Git tags are now standardized on the `v` prefix (e.g., `v1.0.0` instead of `1.0.0`). The Docker workflow (`docker.yaml`) now triggers on `v*.*.*` tag patterns and includes a step to extract the version number without the `v` prefix for the Docker image tag. The existing un-prefixed tags (`0.7.0` through `0.10.0`) remain in the repository but all future releases will use the `v` prefix.
|
||||||
|
|
||||||
|
### 9.2 Stale develop Branch in CI Triggers [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The `tests.yaml` and `develop.yaml` workflows no longer reference the `develop` branch, which has been pruned. CI now triggers on `main` only (for push) and `main` only (for pull requests).
|
||||||
|
|
||||||
|
### 9.3 publish.yaml Fails on Re-run [LOW PRIORITY] ✅ Addressed
|
||||||
|
|
||||||
|
**Current state:** Fixed. The GitHub sync workflow (`publish.yaml`) now uses `git remote add ... 2>/dev/null || git remote set-url ...` instead of bare `git remote add`, which would fail if the remote already existed from a previous run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Done Well
|
||||||
|
|
||||||
|
- **Listener-based architecture** is a good fit for this use case — each listener has a single responsibility, and the priority chain creates a clear request processing pipeline.
|
||||||
|
- **Cookie security** is excellent: `__Host-` prefix, `Secure`, `HttpOnly`, `SameSite=Strict`, and a separate non-prefixed cookie for domain-scoped central auth. Cookie name and domain selection logic is now shared via `CookieNameTrait::sessionCookieName()` and `sessionCookieDomain()`.
|
||||||
|
- **Nonce-based replay protection** with single-use, TTL-limited nonces and collision retry is well-designed. The nonce also serves as CSRF protection for the POST form path.
|
||||||
|
- **Rate limiting** with compound sliding windows (burst + sustained) and the humorous teapot option is practical and well-implemented.
|
||||||
|
- **Test suite** is exemplary: 100% coverage, good use of test helpers, functional tests that exercise the full kernel, and edge cases like ULID collisions and nonce reuse.
|
||||||
|
- **Dual-layer cache** (APCu + filesystem with change tracking) is a clever solution for persistence without a database.
|
||||||
|
- **Backup code system** with single-use enforcement, case-insensitivity, and audit trail (keeping consumed codes with `false` value) is well thought out. Backup code values are no longer logged.
|
||||||
|
- **Interfaces** (`LoginInterface`, `DomainInterface`, `BackupCodeInterface`) enable clean mocking in tests. All now have `declare(strict_types=1)`.
|
||||||
|
- **FrankenPHP worker mode** via the Caddyfile and Dockerfile is a modern, performant serving strategy.
|
||||||
|
- **Security headers** are now set on all responses via `SecurityHeadersListener`.
|
||||||
|
- **Error handling** in cache-dependent listeners now fails closed (denies access on cache errors) rather than propagating 500 errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Originally prepared as a design review. Updated to reflect the state of the `fix/v1.0-must-fix` branch.*
|
||||||
+17
-2
@@ -31,17 +31,32 @@ RUN composer install --no-dev --optimize-autoloader
|
|||||||
RUN composer dump-env prod --empty
|
RUN composer dump-env prod --empty
|
||||||
|
|
||||||
# start creating final image
|
# start creating final image
|
||||||
FROM dunglas/frankenphp:php8.5-trixie
|
# Named `app` so docker-bake.hcl can target it explicitly. Naming the final
|
||||||
|
# stage changes nothing for a plain `docker build` — the last stage is still
|
||||||
|
# the default build target.
|
||||||
|
FROM dunglas/frankenphp:php8.5-trixie AS app
|
||||||
|
|
||||||
# install APCu
|
# install APCu and curl (needed for healthcheck)
|
||||||
RUN pecl install apcu && \
|
RUN pecl install apcu && \
|
||||||
docker-php-ext-enable apcu
|
docker-php-ext-enable apcu
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# symfony required environment variables
|
# symfony required environment variables
|
||||||
ENV APP_DEBUG=0
|
ENV APP_DEBUG=0
|
||||||
ENV APP_ENV=prod
|
ENV APP_ENV=prod
|
||||||
ENV APP_SHARE_DIR=/data/preauth
|
ENV APP_SHARE_DIR=/data/preauth
|
||||||
|
|
||||||
|
# worker thread lifecycle: restart each PHP thread after N requests to
|
||||||
|
# contain slow memory growth. Matches the previous default loop count of
|
||||||
|
# runtime/frankenphp-symfony (removed in the Symfony 8.1 upgrade).
|
||||||
|
# Expose as a build arg so images can bake in a different default;
|
||||||
|
# MAX_REQUESTS=0 disables restarts. Runtime override: the same env var is
|
||||||
|
# read by the Caddyfile placeholder.
|
||||||
|
ARG MAX_REQUESTS=500
|
||||||
|
ENV MAX_REQUESTS=$MAX_REQUESTS
|
||||||
|
|
||||||
# load application into final image
|
# load application into final image
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /data/preauth /data/preauth
|
COPY --from=build /data/preauth /data/preauth
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 digitaladapt
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+487
@@ -0,0 +1,487 @@
|
|||||||
|
# Preauth — Project Roadmap
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Preauth is a pre-authentication gate for self-hosted services. It sits
|
||||||
|
between a reverse proxy (Caddy's `forward_auth`) and your web service,
|
||||||
|
requiring a TOTP code (or backup code) before traffic ever reaches the
|
||||||
|
protected application. It is **not** a replacement for the service's own
|
||||||
|
authentication — it's a gate that prevents outsiders from even seeing
|
||||||
|
what service is running.
|
||||||
|
|
||||||
|
- **Location:** `projects/preauth/`
|
||||||
|
- **Framework:** Symfony 8.1 (PHP ≥ 8.4)
|
||||||
|
- **Serving:** FrankenPHP (Docker image)
|
||||||
|
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
|
||||||
|
- **Auth:** TOTP (single secret) + single-use backup codes
|
||||||
|
- **Production status:** Running in production since June 2024
|
||||||
|
|
||||||
|
### Current Production Use
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|-------------|--------------------------------------------------|
|
||||||
|
| Bitwarden | Password manager — always accessible, invisible to the world |
|
||||||
|
| Microbin | Sharing text blobs and small files across devices |
|
||||||
|
| Gitea | Code hosting — some DNS configs must be public |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → Caddy → forward_auth → Preauth listeners (priority order) → 200/401/418
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
|
||||||
|
If found → `200 OK` + `Remote-User` header → Caddy proxies to backend.
|
||||||
|
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
|
||||||
|
valid IP-based session. If found → `200 OK` + `Remote-User`.
|
||||||
|
3. **PublicAccessListener** (priority 84) — If `PUBLIC_PATHS` is
|
||||||
|
configured and the request matches a public path pattern, applies
|
||||||
|
per-IP rate limiting. Within limit → `200 OK`. Over limit → `429`.
|
||||||
|
Authenticated users never reach this listener.
|
||||||
|
4. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
|
||||||
|
exceeded login attempt threshold → `418 I'm a Teapot` (or `429`).
|
||||||
|
5. **LoginListener** (priority 66) — Detects login attempts via
|
||||||
|
`X-Preauth` header (base64url JSON) or POST form on auth subdomain.
|
||||||
|
Validates TOTP/backup codes through `LoginManager`.
|
||||||
|
6. **InterceptListener** (priority 55) — Fallback: if no listener has
|
||||||
|
set a response, either redirects to auth subdomain (central auth) or
|
||||||
|
renders the Twig login page with a fresh nonce.
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
- **No controllers** — Entirely event-listener-driven. Clean separation
|
||||||
|
of concerns, each listener handles one stage of the auth flow.
|
||||||
|
- **Dual-layer cache** — APCu for fast in-memory lookups, file-based
|
||||||
|
storage for persistence across container restarts. `MonitorCacheKeys`
|
||||||
|
wraps the PSR-6 pool to track key changes for efficient persistence
|
||||||
|
(only write what changed).
|
||||||
|
- **`__Host-` prefixed cookies** — `SameSite=Strict`, `Secure`,
|
||||||
|
`HttpOnly`. Central auth mode uses a separate `__Http-Domain-Preauth`
|
||||||
|
cookie name (domain-scoped, no `__Host-` prefix).
|
||||||
|
- **Nonce system** — 15-byte random nonces, single-use, 120s TTL, with
|
||||||
|
retry-on-collision (up to 3 attempts).
|
||||||
|
- **TOTP with ±1 period leeway (±30 seconds)** — Accommodates clock drift.
|
||||||
|
- **Backup codes** — Case-insensitive alphanumeric, single-use, stored
|
||||||
|
in cache with year-2999 expiry. Generated via console command.
|
||||||
|
- **Domain awareness** — `DomainManager` handles multi-part TLDs
|
||||||
|
(`.co.uk`, `.com.au`, etc.) with a built-in TLD lookup table.
|
||||||
|
- **Interfaces** — `LoginInterface`, `DomainInterface`,
|
||||||
|
`BackupCodeInterface` extracted to support testing (mockable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Suite Status
|
||||||
|
|
||||||
|
### Current Results
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------------|--------------------------------|
|
||||||
|
| **Tests** | 293 |
|
||||||
|
| **Assertions** | 605 |
|
||||||
|
| **Pass** | 222 (100%) |
|
||||||
|
| **Fail** | 0 |
|
||||||
|
| **Errors** | 0 |
|
||||||
|
| **Warnings** | 0 |
|
||||||
|
| **Time** | ~0.56s (without coverage) |
|
||||||
|
| | ~1.31s (with coverage) |
|
||||||
|
|
||||||
|
### Code Coverage
|
||||||
|
|
||||||
|
| Metric | Percentage |
|
||||||
|
|----------|---------------------|
|
||||||
|
| **Lines** | **100.00%** (442/442) |
|
||||||
|
| **Methods** | **100.00%** (83/83) |
|
||||||
|
| **Classes** | **100.00%** (21/21) |
|
||||||
|
|
||||||
|
Every class, method, and line in `src/` is covered.
|
||||||
|
|
||||||
|
### Source → Test Mapping
|
||||||
|
|
||||||
|
| Source File | Test File | Type |
|
||||||
|
|------------------------------------------|----------------------------------------------------|----------|
|
||||||
|
| `Clock.php` | `Unit/ClockTest.php` | Unit |
|
||||||
|
| `ConfigBag.php` | `Unit/ConfigBagTest.php` | Unit |
|
||||||
|
| `Kernel.php` | (covered via functional tests) | Functional |
|
||||||
|
| `MonitorCacheKeys.php` | `Unit/MonitorCacheKeysTest.php` | Unit |
|
||||||
|
| `PersistCache.php` | `Unit/PersistCacheTest.php` | Unit |
|
||||||
|
| `Utilities.php` | `Unit/UtilitiesTest.php` | Unit |
|
||||||
|
| `Command/GenerateBackupCodesCommand.php` | `Unit/Command/GenerateBackupCodesCommandTest.php` | Unit |
|
||||||
|
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
|
||||||
|
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.php` | Unit |
|
||||||
|
| `Listener/AcceptListener.php` | `Unit/Listener/AcceptListenerTest.php` | Unit |
|
||||||
|
| `Listener/PublicAccessListener.php` | `Unit/Listener/PublicAccessListenerTest.php` | Unit |
|
||||||
|
| `Listener/AllowListener.php` | `Unit/Listener/AllowListenerTest.php` | Unit |
|
||||||
|
| `Listener/InterceptListener.php` | `Unit/Listener/InterceptListenerTest.php` | Unit |
|
||||||
|
| `Listener/LoginListener.php` | `Unit/Listener/LoginListenerTest.php` | Unit |
|
||||||
|
| `Listener/RejectListener.php` | `Unit/Listener/RejectListenerTest.php` | Unit |
|
||||||
|
| `Service/BackupCodeManager.php` | `Unit/Service/BackupCodeManagerTest.php` | Unit |
|
||||||
|
| `Service/DomainManager.php` | `Unit/Service/DomainManagerTest.php` | Unit |
|
||||||
|
| `Service/PublicPathMatcher.php` | `Unit/Service/PublicPathMatcherTest.php` | Unit |
|
||||||
|
| `Service/LoginManager.php` | `Unit/Service/LoginManagerTest.php` | Unit |
|
||||||
|
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.php` | Unit |
|
||||||
|
| `Trait/GetTotpTrait.php` | `Unit/Trait/GetTotpTraitTest.php` | Unit |
|
||||||
|
| `Trait/HasLoggerTrait.php` | `Unit/Trait/HasLoggerTraitTest.php` | Unit |
|
||||||
|
| `Trait/MakeNonceTrait.php` | `Unit/Trait/MakeNonceTraitTest.php` | Unit |
|
||||||
|
| `Trait/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
|
||||||
|
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
|
||||||
|
| *(Public access flow)* | `Functional/PublicAccessFlowTest.php` | Functional |
|
||||||
|
|
||||||
|
### Test Quality Assessment
|
||||||
|
|
||||||
|
**Strengths:**
|
||||||
|
- **100% coverage** — every line, method, and class.
|
||||||
|
- **Well-structured test hierarchy** — Unit tests per class, functional
|
||||||
|
tests for the full HTTP kernel flow. Two support traits
|
||||||
|
(`TotpTestHelper`, `ListenerTestHelper`) provide reusable fixtures
|
||||||
|
(frozen clock, deterministic TOTP, Twig environment, mock rate
|
||||||
|
limiters).
|
||||||
|
- **Edge cases well-covered** — ULID collision handling, nonce collision
|
||||||
|
retries, spent nonces, invalid payloads (bad base64, non-object JSON,
|
||||||
|
arrays, null, booleans), empty/whitespace fields, field truncation,
|
||||||
|
multibyte characters in cache keys, multi-part TLD domain matching,
|
||||||
|
cookie pruning on invalid sessions.
|
||||||
|
- **Both positive and negative paths** — Every listener tests both
|
||||||
|
success and failure scenarios.
|
||||||
|
- **Security-conscious testing** — Backup code single-use enforcement,
|
||||||
|
case-insensitivity, character stripping, rate limit teapot vs.
|
||||||
|
too-many-requests, return URL validation (prevents open redirect),
|
||||||
|
cookie security attributes.
|
||||||
|
- **Realistic functional tests** — `AuthenticationFlowTest` goes through
|
||||||
|
the actual Symfony kernel: fetches nonces from rendered HTML, submits
|
||||||
|
TOTP codes, verifies cookies are set, tests the full login →
|
||||||
|
authenticated access cycle.
|
||||||
|
- **Smart test infrastructure** — `KernelBrowser::disableReboot()` used
|
||||||
|
in functional tests so nonces persist across requests (matching
|
||||||
|
production APCu behavior).
|
||||||
|
|
||||||
|
**Status: Test suite goal is met.** 222 tests, 100% coverage, all passing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Phase 1 — Public but Rate-Limited Access ✅ Completed (v1.1)
|
||||||
|
|
||||||
|
**Goal:** Allow select services to be publicly accessible (no TOTP
|
||||||
|
required) but with aggressive per-IP rate limiting to prevent bot
|
||||||
|
traffic from overwhelming the server.
|
||||||
|
|
||||||
|
**Context:** The user previously made Gitea semi-public (view but no
|
||||||
|
login), but bot traffic slowed the server and consumed all household
|
||||||
|
bandwidth, forcing it back to fully private. The solution isn't more
|
||||||
|
authentication — it's bandwidth/resource protection for public-facing
|
||||||
|
services.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
- New config variables:
|
||||||
|
- `PUBLIC_PATHS` — Comma-separated path patterns with `*` (single
|
||||||
|
segment) and `**` (cross-segment) wildcard support. Optional host
|
||||||
|
prefix (e.g., `code.example.com/public/**`). When empty (default),
|
||||||
|
the feature is fully disabled.
|
||||||
|
- `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` — Burst rate limiting
|
||||||
|
(default: 100 requests per 60 seconds).
|
||||||
|
- `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` — Sustained rate limiting
|
||||||
|
(default: 500 requests per 3600 seconds).
|
||||||
|
|
||||||
|
- New listener: **PublicAccessListener** (priority 84, after
|
||||||
|
AcceptListener and AllowListener, before RejectListener):
|
||||||
|
- Checks if the request path matches a configured public path pattern.
|
||||||
|
- If public and within rate limit → `200 OK` (no `Remote-User` header).
|
||||||
|
- If public and over rate limit → `429 Too Many Requests` with
|
||||||
|
`Retry-After` header.
|
||||||
|
- Authenticated users bypass this listener entirely (AcceptListener
|
||||||
|
or AllowListener returns 200 first).
|
||||||
|
|
||||||
|
- New service: **PublicPathMatcher** — Parses path patterns and matches
|
||||||
|
request paths with wildcard support.
|
||||||
|
|
||||||
|
- Separate `public_limiter` compound rate limiter (independent from
|
||||||
|
the login attempt rate limiter).
|
||||||
|
|
||||||
|
- [x] Design public path detection mechanism (path-based with wildcards)
|
||||||
|
- [x] Implement `PublicAccessListener` with separate rate limiter pool
|
||||||
|
- [x] Add config variables and defaults
|
||||||
|
- [x] Update Caddyfile example with public service snippet
|
||||||
|
- [x] Tests for public mode (within limit, over limit, burst behavior)
|
||||||
|
- [x] Documentation in README
|
||||||
|
|
||||||
|
### Phase 2 — Session Management & Audit
|
||||||
|
|
||||||
|
**Goal:** Give visibility into who has access and when it was granted.
|
||||||
|
|
||||||
|
- [ ] **Active sessions view** — Console command or simple API endpoint
|
||||||
|
to list active sessions (cookie-based and IP-based), showing:
|
||||||
|
- Session ID / username
|
||||||
|
- IP address
|
||||||
|
- First auth timestamp
|
||||||
|
- Last seen timestamp
|
||||||
|
- Scope (cookie vs. IP)
|
||||||
|
- [ ] **Session revocation** — Console command to revoke a specific
|
||||||
|
session by ID or revoke all sessions for an IP.
|
||||||
|
- [ ] **Audit log** — Log every successful and failed authentication
|
||||||
|
attempt to a persistent store (file-based JSONL, similar to the email
|
||||||
|
integration's audit log):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2025-01-15T14:23:01Z",
|
||||||
|
"ip": "192.168.1.50",
|
||||||
|
"action": "login_success",
|
||||||
|
"username": "mom",
|
||||||
|
"method": "totp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Tests for all new commands and endpoints
|
||||||
|
|
||||||
|
### Phase 2b — Backup Code System Completion
|
||||||
|
|
||||||
|
**Goal:** Finish the backup code system — the core logic is solid but
|
||||||
|
the management surface is incomplete.
|
||||||
|
|
||||||
|
**What already exists:**
|
||||||
|
- ✅ `BackupCodeManager::generate()` — Creates codes, saves to cache
|
||||||
|
with year-2999 expiry
|
||||||
|
- ✅ `BackupCodeManager::expire()` — Deletes all `backup_` prefixed
|
||||||
|
keys from cache
|
||||||
|
- ✅ `BackupCodeManager::verifyAndConsume()` — Validates and marks code
|
||||||
|
as used (sets value to `false`, keeps the key for audit trail)
|
||||||
|
- ✅ `app:generate-backup-codes [count]` console command
|
||||||
|
- ✅ Tests for all of the above (100% coverage)
|
||||||
|
|
||||||
|
**What's missing:**
|
||||||
|
|
||||||
|
- [ ] **`app:list-backup-codes` command** — Show backup code status:
|
||||||
|
- Total codes generated
|
||||||
|
- How many are still valid (unused)
|
||||||
|
- How many have been spent (and optionally when)
|
||||||
|
- Output format: table with status column (✅ valid / ⛔ used)
|
||||||
|
- Note: spent codes are kept in cache with value `false`, so we can
|
||||||
|
distinguish "used" from "never existed" — this is good design
|
||||||
|
|
||||||
|
- [ ] **`app:expire-backup-codes` command** — Wrap the existing
|
||||||
|
`BackupCodeManager::expire()` method in a console command. Should:
|
||||||
|
- Show how many codes are being expired before confirmation
|
||||||
|
- Support `--force` flag to skip confirmation prompt
|
||||||
|
- Call `persistCache->boot()` and `persistCache->persist()` like the
|
||||||
|
generate command does (since `Kernel::terminate()` doesn't run in
|
||||||
|
CLI)
|
||||||
|
|
||||||
|
- [ ] **Notification on backup code use** — When
|
||||||
|
`verifyAndConsume()` consumes a backup code, fire a notification
|
||||||
|
through configurable channels:
|
||||||
|
- Discord webhook (we already have the `discord.sh` infrastructure)
|
||||||
|
- ntfy
|
||||||
|
- Email (once email integration is available)
|
||||||
|
- Webhook (generic HTTP POST for future integrations)
|
||||||
|
- Config variables:
|
||||||
|
- `BACKUP_CODE_NOTIFY=discord,ntfy` — comma-separated channels
|
||||||
|
- `BACKUP_CODE_NOTIFY_WEBHOOK=''` — generic webhook URL
|
||||||
|
- Message should include: timestamp, IP address, username, and how
|
||||||
|
many valid codes remain
|
||||||
|
- Architecture: `BackupCodeManager` dispatches an event
|
||||||
|
(e.g. `BackupCodeUsedEvent`) after consuming a code. A listener
|
||||||
|
handles the notification dispatch. This keeps the notification
|
||||||
|
logic out of the backup code manager itself.
|
||||||
|
|
||||||
|
- [ ] **Low-codes warning** — If backup codes fall below a threshold
|
||||||
|
(e.g. 3 remaining), include a warning in the notification and/or
|
||||||
|
surface it in the `list-backup-codes` command output
|
||||||
|
|
||||||
|
- [ ] Tests for all new commands and notification dispatch
|
||||||
|
|
||||||
|
### Phase 2c — Passkey Authentication
|
||||||
|
|
||||||
|
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
|
||||||
|
authentication method alongside TOTP and backup codes.
|
||||||
|
|
||||||
|
**Context:** Passkeys are the modern standard for passwordless auth.
|
||||||
|
They're phishing-resistant (domain-bound), use biometrics or device
|
||||||
|
PINs, and are significantly more user-friendly than typing 6-digit
|
||||||
|
codes. For a pre-auth gate that friends and family use, passkeys would
|
||||||
|
be a major UX improvement — especially for non-technical users who
|
||||||
|
struggle with TOTP apps.
|
||||||
|
|
||||||
|
**Design considerations:**
|
||||||
|
|
||||||
|
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
|
||||||
|
secret shared with all devices), each device registers its own
|
||||||
|
passkey. This is actually better for a family-use gate — you can
|
||||||
|
register mom's phone separately from dad's laptop.
|
||||||
|
|
||||||
|
- WebAuthn requires a **challenge-response flow**:
|
||||||
|
1. Client requests a challenge (preauth generates and stores a
|
||||||
|
challenge nonce, similar to the existing nonce system)
|
||||||
|
2. Browser prompts for biometric/PIN, creates a signed assertion
|
||||||
|
3. Server verifies the assertion against the registered credential
|
||||||
|
|
||||||
|
- This is a **two-step flow** unlike TOTP's single-step, which means
|
||||||
|
the login page JS and `LoginListener` need to handle an additional
|
||||||
|
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
|
||||||
|
is a good foundation — extend it with a "use passkey" button that
|
||||||
|
initiates the `navigator.credentials.get()` flow.
|
||||||
|
|
||||||
|
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
|
||||||
|
Symfony bundle available). Would add registration ceremony (console
|
||||||
|
command or initial-setup flow to register a passkey).
|
||||||
|
|
||||||
|
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
||||||
|
8.1 and FrankenPHP
|
||||||
|
- [ ] Design passkey registration flow (console command? first-visit
|
||||||
|
setup? separate registration endpoint?)
|
||||||
|
- [ ] Implement challenge generation and storage (extend existing
|
||||||
|
nonce/cache infrastructure)
|
||||||
|
- [ ] Implement assertion verification in a new `PasskeyManager`
|
||||||
|
service (implements a shared `AuthMethodInterface`?)
|
||||||
|
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
|
||||||
|
- [ ] Handle multiple registered passkeys (per-device)
|
||||||
|
- [ ] Console command: `app:list-passkeys` — show registered devices
|
||||||
|
- [ ] Console command: `app:remove-passkey` — revoke a passkey
|
||||||
|
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
|
||||||
|
- [ ] Tests for registration, authentication, and revocation
|
||||||
|
- [ ] Consider: should passkeys be a *replacement* for TOTP or an
|
||||||
|
*alternative*? (Probably alternative — keep TOTP as fallback)
|
||||||
|
|
||||||
|
### Phase 3 — Multi-User Support
|
||||||
|
|
||||||
|
**Goal:** Support multiple TOTP users for household/family access.
|
||||||
|
|
||||||
|
*Note: This is a significant feature that changes the single-secret
|
||||||
|
model. It should only be pursued if the single-secret + backup codes
|
||||||
|
approach proves insufficient for the use case.*
|
||||||
|
|
||||||
|
- [ ] Multiple TOTP secrets, each with a label (e.g., "mom", "dad",
|
||||||
|
"friend")
|
||||||
|
- [ ] Per-user backup codes
|
||||||
|
- [ ] Per-user session tracking (the `username` field in Payload already
|
||||||
|
supports this — sessions are already tagged with an ID)
|
||||||
|
- [ ] Console command to add/remove/list users
|
||||||
|
- [ ] Consider: should the login page ask for a username, or should all
|
||||||
|
TOTP codes be tried against all secrets? (Username is better —
|
||||||
|
it's already in the payload.)
|
||||||
|
- [ ] Tests for multi-user scenarios
|
||||||
|
|
||||||
|
### Phase 4 — Polish & Hardening
|
||||||
|
|
||||||
|
**Goal:** Production hardening and quality-of-life improvements.
|
||||||
|
|
||||||
|
- [ ] **Docker image improvements:**
|
||||||
|
- Multi-arch builds (amd64 + arm64 for Raspberry Pi)
|
||||||
|
- Smaller image size (alpine-based if feasible)
|
||||||
|
- Better health check (actual endpoint, not just `curl localhost`)
|
||||||
|
- [ ] **GitHub/Gitea repository polish:**
|
||||||
|
- ✅ Comprehensive README with setup guide, architecture overview, and
|
||||||
|
configuration reference
|
||||||
|
- Contributing guidelines
|
||||||
|
- ✅ Changelog formalised (CHANGELOG.md)
|
||||||
|
- ✅ CI workflows (tests + php-cs-fixer on push/PR, Docker image on tag)
|
||||||
|
- [ ] **Security review:**
|
||||||
|
- ✅ CSRF protection on the POST form login — nonce system documented
|
||||||
|
- ✅ Security headers added (X-Content-Type-Options, X-Frame-Options, CSP, etc.)
|
||||||
|
- Review nonce entropy and cache key collision space
|
||||||
|
- Consider session fixation protections
|
||||||
|
- [ ] **Frontend improvements:**
|
||||||
|
- Mobile-responsive login page audit
|
||||||
|
- Accessibility audit (ARIA labels, keyboard navigation)
|
||||||
|
- Dark mode (if not already — the teal background suggests it might
|
||||||
|
already be dark-themed)
|
||||||
|
- [ ] **Logging improvements:**
|
||||||
|
- Structured logging (JSON format option) for easier parsing
|
||||||
|
- Log rotation configuration
|
||||||
|
- Debug mode documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Thoughts
|
||||||
|
|
||||||
|
Based on the review, here are features that might be missing or worth
|
||||||
|
considering, keeping in mind that preauth is a **gate**, not a full
|
||||||
|
identity provider:
|
||||||
|
|
||||||
|
### High Value
|
||||||
|
|
||||||
|
1. **Public but rate-limited mode** (Phase 1) — Directly solves the
|
||||||
|
Gitea bot traffic problem. This is the most impactful missing
|
||||||
|
feature.
|
||||||
|
|
||||||
|
2. **Passkey authentication** (Phase 2c) — Phishing-resistant,
|
||||||
|
passwordless auth that's far more user-friendly than TOTP for
|
||||||
|
non-technical family members. The modern standard for this kind
|
||||||
|
of gate.
|
||||||
|
|
||||||
|
3. **Backup code notifications** (Phase 2b) — When a backup code is
|
||||||
|
used, you should know about it immediately. This is a security-critical
|
||||||
|
event — it means someone lost their device or is locked out of their
|
||||||
|
TOTP app. Discord/ntfy/email notification should fire automatically.
|
||||||
|
|
||||||
|
4. **Backup code management commands** (Phase 2b) — The `generate`
|
||||||
|
command exists, but `list` and `expire` commands are missing despite
|
||||||
|
the underlying methods (`expire()`) already being implemented.
|
||||||
|
|
||||||
|
5. **Session visibility and revocation** (Phase 2) — Currently there's
|
||||||
|
no way to see who has access or revoke a session without clearing
|
||||||
|
the entire cache. For a security tool, this is important.
|
||||||
|
|
||||||
|
6. **Audit log** (Phase 2) — For a security gate, not having an audit
|
||||||
|
trail of logins (successful and failed) is a gap. The data is logged
|
||||||
|
at debug level, but not persisted in a queryable format.
|
||||||
|
|
||||||
|
### Medium Value
|
||||||
|
|
||||||
|
4. **Health check endpoint** — The Dockerfile has a `HEALTHCHECK` that
|
||||||
|
just `curl`s localhost, but a dedicated `/health` endpoint that
|
||||||
|
verifies cache connectivity would be more meaningful.
|
||||||
|
|
||||||
|
5. **Graceful degradation** — If the file-based cache is corrupted or
|
||||||
|
unavailable, does preauth fail open or closed? Should be documented
|
||||||
|
and tested. (Currently the `PersistCache` handles this in `boot()`,
|
||||||
|
but edge cases around partial corruption could be explored.)
|
||||||
|
|
||||||
|
6. **Rate limit headers** — Adding `X-RateLimit-Remaining` and
|
||||||
|
`Retry-After` headers to rate-limited responses would help legitimate
|
||||||
|
clients back off gracefully.
|
||||||
|
|
||||||
|
### Lower Value (Nice to Have)
|
||||||
|
|
||||||
|
7. **WebSocket support** — If protected services use WebSocket
|
||||||
|
connections, does `forward_auth` handle the upgrade handshake? This
|
||||||
|
is likely a Caddy configuration concern, but worth documenting.
|
||||||
|
|
||||||
|
8. **Theming presets** — Beyond the current env-var colour config,
|
||||||
|
preset themes or custom CSS upload could be nice for personalisation.
|
||||||
|
|
||||||
|
9. **TOTP secret rotation** — Console command to generate a new TOTP
|
||||||
|
secret and invalidate all existing sessions. Useful if a device is
|
||||||
|
lost or compromised.
|
||||||
|
|
||||||
|
10. **Per-service authentication policies** — Different services could
|
||||||
|
require different authentication strength (e.g., Bitwarden requires
|
||||||
|
TOTP + recent login, Microbin accepts any valid session). This would
|
||||||
|
need Caddy configuration support to pass the policy to preauth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Status
|
||||||
|
|
||||||
|
| Branch | Status | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| `main` (0.10.0) | Production | Current stable release |
|
||||||
|
|
||||||
|
All feature branches have been pruned. Development uses a feature-branch + PR workflow into `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship to Other Projects
|
||||||
|
|
||||||
|
| Project | Integration |
|
||||||
|
|---------|-------------|
|
||||||
|
| MCP server | Preauth could be registered as an MCP command for session management ("revoke all sessions", "who's logged in?") |
|
||||||
|
| Email integration | Audit log entries could be included in morning summary ("2 failed login attempts from 203.0.113.50 overnight") |
|
||||||
|
| Discord/ntfy | Alert on backup code usage, suspicious activity (rate limit triggered, multiple failed attempts from new IP), low backup code count |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Prepared by Lyra, your office-side assistant. ✨*
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| unreleased (v1 development) | ✅ |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Report vulnerabilities privately to **security@digitaladapt.com** (or open a private
|
||||||
|
security advisory on the repository). Please include reproduction steps and affected
|
||||||
|
versions. You will receive an acknowledgement within 48 hours and a status update at
|
||||||
|
least weekly until resolution.
|
||||||
|
|
||||||
|
**Do not open a public issue for a suspected vulnerability.** preauth is an
|
||||||
|
authentication gateway — it sits in front of every protected service, so a
|
||||||
|
weakness here is a weakness everywhere behind it.
|
||||||
|
|
||||||
|
## Security model summary
|
||||||
|
|
||||||
|
preauth implements the auth half of the `forward_auth` pattern: a reverse proxy
|
||||||
|
calls it per request to decide whether a request may reach the upstream service.
|
||||||
|
|
||||||
|
- **Two outcomes per request: allow or intercept.** `AcceptListener` /
|
||||||
|
`RejectListener` / `InterceptListener` decide, and the decision is made on
|
||||||
|
every request rather than cached — a cached auth session is an anti-pattern
|
||||||
|
(GUIDING-LIGHT §3.3d), which is also why this project gets **no service
|
||||||
|
worker**.
|
||||||
|
- **The login flow is never cached.** The login page, failed logins, redirects
|
||||||
|
and rate-limit responses are sent with
|
||||||
|
`Cache-Control: no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0`.
|
||||||
|
An aggressive cache (notably older Safari) replaying a stale pre-auth response
|
||||||
|
presents to the user as being logged back out after a refresh.
|
||||||
|
- **Headers are set by the app, not left to the proxy.** `X-Content-Type-Options:
|
||||||
|
nosniff`, `X-Frame-Options: DENY`, a Content-Security-Policy, and
|
||||||
|
`Strict-Transport-Security: max-age=31536000`. Docs recommend mirroring the
|
||||||
|
caching headers at the edge as defence in depth, but the app does not depend
|
||||||
|
on it.
|
||||||
|
- **TOTP is required.** Secrets come from `TOTP_URI`; if it is unset the app
|
||||||
|
generates one and prints it for enrolment. Login state is carried in a signed
|
||||||
|
payload (`src/Data/Payload.php`) bound to a nonce and a scope, not in a
|
||||||
|
server-side session store.
|
||||||
|
- **Rate limiting is on by default**, with the block response configurable
|
||||||
|
(`TEAPOT=false` returns 429 rather than 418).
|
||||||
|
- **`REMOTE_USER` is trusted input, not a secret.** In `remote_user` modes the
|
||||||
|
gateway accepts an upstream-asserted identity, so the upstream must be the
|
||||||
|
only path to the app. Do not expose preauth directly to the internet for this
|
||||||
|
mode.
|
||||||
|
- **`.env` is never committed; secrets are env vars injected at runtime.** Real
|
||||||
|
secrets belong in `.env.local` or `bin/console secrets:set`, read via
|
||||||
|
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope: the application code in `src/`, the shipped `Caddyfile`, the
|
||||||
|
`Dockerfile`, and anything that affects the allow/intercept decision.
|
||||||
|
|
||||||
|
Out of scope: the `forward_auth` integration at the edge (a host-proxy
|
||||||
|
configuration concern, see `docs/examples/Caddyfile`) and the security of the
|
||||||
|
services preauth protects.
|
||||||
|
|
||||||
|
## Deployment note
|
||||||
|
|
||||||
|
preauth runs as a container and, once
|
||||||
|
[GUIDING-LIGHT §6.4](https://code.devgnome.com/private/ci) is adopted here, will
|
||||||
|
drop privileges via `USER`. The image declares `VOLUME ["/config", "/data"]`;
|
||||||
|
if you pin a `user:` in your compose file, that user must be able to write both
|
||||||
|
paths — otherwise login state and backup codes cannot be persisted.
|
||||||
Executable
+265
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PreAuth Dev Server Script
|
||||||
|
#
|
||||||
|
# Manages a local PHP dev server for end-to-end development and testing.
|
||||||
|
# Binds to 0.0.0.0 so the app is accessible via a reverse proxy (Caddy) for
|
||||||
|
# browser-based visual verification.
|
||||||
|
#
|
||||||
|
# PreAuth is a TOTP-based authentication gateway. It uses APCu for nonce/cache
|
||||||
|
# and filesystem for session persistence — no database needed. The dev server
|
||||||
|
# runs with APP_ENV=dev and APP_DEBUG=1 for live troubleshooting.
|
||||||
|
#
|
||||||
|
# Self-bootstrapping: the `start` command checks for required system packages
|
||||||
|
# (PHP, extensions, tools), Composer, and project dependencies — installing
|
||||||
|
# them automatically if missing. This means the script works even after a
|
||||||
|
# terminal reset/reboot, embracing the self-cleaning container design.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bin/dev.sh start Start the dev server (auto-installs deps if needed)
|
||||||
|
# bin/dev.sh stop Stop the dev server
|
||||||
|
# bin/dev.sh status Check if the dev server is running
|
||||||
|
# bin/dev.sh restart Stop and start the dev server
|
||||||
|
#
|
||||||
|
# Port assignment (P-R-E = 7-7-3):
|
||||||
|
# 8773 → https://preauth.lyra-dev.devgnome.com
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
PORT=8773
|
||||||
|
HOST="0.0.0.0"
|
||||||
|
ENV="dev"
|
||||||
|
DEV_SECRET="dev_secret_not_for_production_use_only"
|
||||||
|
PID_FILE="var/.dev-server.pid"
|
||||||
|
LOG_FILE="var/log/dev-server.log"
|
||||||
|
|
||||||
|
# Required PHP extensions (checked via php -m)
|
||||||
|
REQUIRED_PHP_EXTS=(
|
||||||
|
ctype
|
||||||
|
iconv
|
||||||
|
mbstring
|
||||||
|
apcu
|
||||||
|
dom
|
||||||
|
SimpleXML
|
||||||
|
xml
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apt packages for PHP + extensions
|
||||||
|
# Note: preauth uses Symfony 8.1 which requires PHP >=8.4.
|
||||||
|
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
|
||||||
|
PHP_APT_PACKAGES=(
|
||||||
|
php8.4-cli
|
||||||
|
php8.4-common # ctype, iconv
|
||||||
|
php8.4-mbstring
|
||||||
|
php8.4-xml # dom, SimpleXML, xml
|
||||||
|
php8.4-opcache
|
||||||
|
php8.4-readline
|
||||||
|
php8.4-apcu # APCu — critical for nonce cache, rate limiter, sessions
|
||||||
|
)
|
||||||
|
|
||||||
|
# System tools needed
|
||||||
|
SYSTEM_TOOLS=(
|
||||||
|
git
|
||||||
|
unzip
|
||||||
|
curl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve project root (script lives in bin/)
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Ensure var directory structure exists
|
||||||
|
mkdir -p var/log var/share
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
is_running() {
|
||||||
|
if [[ ! -f "$PID_FILE" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$PID_FILE")"
|
||||||
|
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
print_status() {
|
||||||
|
if is_running; then
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$PID_FILE")"
|
||||||
|
echo "✅ PreAuth dev server is RUNNING"
|
||||||
|
echo " PID: $pid"
|
||||||
|
echo " URL: http://localhost:${PORT}"
|
||||||
|
echo " Exposed: http://${HOST}:${PORT}"
|
||||||
|
echo " Dev URL: https://preauth.lyra-dev.devgnome.com"
|
||||||
|
echo " Logs: ${LOG_FILE}"
|
||||||
|
else
|
||||||
|
echo "⛔ PreAuth dev server is STOPPED"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Bootstrap ───────────────────────────────────────────────────────────────
|
||||||
|
# Ensures all system packages, Composer, and project dependencies are present.
|
||||||
|
# Idempotent — if everything is already installed, checks are fast no-ops.
|
||||||
|
# This is what makes the script survive terminal resets/reboots.
|
||||||
|
|
||||||
|
bootstrap() {
|
||||||
|
local needed_packages=()
|
||||||
|
|
||||||
|
# ── Check system tools ──
|
||||||
|
for tool in "${SYSTEM_TOOLS[@]}"; do
|
||||||
|
if ! command -v "$tool" &>/dev/null; then
|
||||||
|
needed_packages+=("$tool")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Check PHP and required extensions ──
|
||||||
|
local php_needs_install=false
|
||||||
|
if ! command -v php &>/dev/null; then
|
||||||
|
php_needs_install=true
|
||||||
|
else
|
||||||
|
for ext in "${REQUIRED_PHP_EXTS[@]}"; do
|
||||||
|
if ! php -m 2>/dev/null | grep -iq "^${ext}$"; then
|
||||||
|
php_needs_install=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$php_needs_install" == "true" ]]; then
|
||||||
|
needed_packages+=("${PHP_APT_PACKAGES[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install missing packages ──
|
||||||
|
if [[ ${#needed_packages[@]} -gt 0 ]]; then
|
||||||
|
echo "→ Installing missing system packages: ${needed_packages[*]}…"
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq "${needed_packages[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ensure APCu is enabled for CLI ──
|
||||||
|
# PreAuth's console commands need APCu; the Dockerfile sets apc.enable_cli=1
|
||||||
|
local apcu_ini="/etc/php/8.4/mods-available/apcu.ini"
|
||||||
|
if [[ -f "$apcu_ini" ]] && ! grep -q 'apc.enable_cli' "$apcu_ini" 2>/dev/null; then
|
||||||
|
echo "→ Enabling APCu CLI support…"
|
||||||
|
echo 'apc.enable_cli=1' | sudo tee -a "$apcu_ini" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ensure Composer is available ──
|
||||||
|
if ! command -v composer &>/dev/null; then
|
||||||
|
echo "→ Installing Composer…"
|
||||||
|
curl -sS https://getcomposer.org/installer | php
|
||||||
|
sudo mv composer.phar /usr/local/bin/composer
|
||||||
|
sudo chmod +x /usr/local/bin/composer
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ensure project dependencies are installed ──
|
||||||
|
if [[ ! -d "vendor/" ]]; then
|
||||||
|
echo "→ Installing Composer dependencies…"
|
||||||
|
APP_ENV=dev composer install --no-interaction
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Commands ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if is_running; then
|
||||||
|
echo "⚠️ Dev server is already running (PID $(cat "$PID_FILE"))"
|
||||||
|
print_status
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Starting PreAuth dev server on ${HOST}:${PORT}…"
|
||||||
|
|
||||||
|
# Self-bootstrap: ensure all dependencies are present
|
||||||
|
bootstrap
|
||||||
|
|
||||||
|
echo "→ Clearing dev cache…"
|
||||||
|
APP_ENV="$ENV" \
|
||||||
|
APP_DEBUG=1 \
|
||||||
|
APP_SECRET="$DEV_SECRET" \
|
||||||
|
php bin/console cache:clear 2>&1 | tail -3
|
||||||
|
|
||||||
|
echo "→ Starting PHP dev server…"
|
||||||
|
APP_ENV="$ENV" \
|
||||||
|
APP_DEBUG=1 \
|
||||||
|
APP_SECRET="$DEV_SECRET" \
|
||||||
|
APP_SHARE_DIR="${PROJECT_ROOT}/var/share" \
|
||||||
|
nohup php -S "${HOST}:${PORT}" -t public/ > "$LOG_FILE" 2>&1 &
|
||||||
|
|
||||||
|
local pid=$!
|
||||||
|
echo "$pid" > "$PID_FILE"
|
||||||
|
|
||||||
|
# Give it a moment to boot
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if is_running; then
|
||||||
|
echo ""
|
||||||
|
print_status
|
||||||
|
else
|
||||||
|
echo "❌ Failed to start dev server. Check logs:"
|
||||||
|
echo " ${LOG_FILE}"
|
||||||
|
tail -20 "$LOG_FILE" 2>/dev/null || true
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if ! is_running; then
|
||||||
|
echo "⚠️ Dev server is not running."
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$PID_FILE")"
|
||||||
|
echo "→ Stopping dev server (PID ${pid})…"
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wait for graceful shutdown
|
||||||
|
local count=0
|
||||||
|
while kill -0 "$pid" 2>/dev/null && [[ $count -lt 10 ]]; do
|
||||||
|
sleep 0.5
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Force kill if still alive
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "→ Process didn't exit gracefully, sending SIGKILL…"
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
echo "✅ Dev server stopped."
|
||||||
|
}
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
stop
|
||||||
|
sleep 1
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: bin/dev.sh {start|stop|status|restart}"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " start Start the dev server (auto-installs deps if needed)"
|
||||||
|
echo " stop Stop the dev server"
|
||||||
|
echo " status Check if the dev server is running"
|
||||||
|
echo " restart Restart the dev server"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
start) start ;;
|
||||||
|
stop) stop ;;
|
||||||
|
status) print_status ;;
|
||||||
|
restart) restart ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
+7
-4
@@ -1,13 +1,16 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Dev utility — builds and runs the preauth container locally.
|
||||||
|
# Not for production use.
|
||||||
|
# APP_SECRET should be set in your environment or .env file.
|
||||||
|
|
||||||
docker container rm preauth
|
docker container rm preauth 2>/dev/null
|
||||||
docker build . -t digtialadapt/preauth:dev
|
docker build . -t digitaladapt/preauth:dev
|
||||||
docker run --name preauth \
|
docker run --name preauth \
|
||||||
-e APP_ENV=dev \
|
-e APP_ENV=dev \
|
||||||
-e APP_DEBUG=true \
|
-e APP_DEBUG=true \
|
||||||
-e APP_SECRET=f88a1074691c40415be4439345b79f69 \
|
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
|
||||||
-e APP_SHARE_DIR=var/share \
|
-e APP_SHARE_DIR=var/share \
|
||||||
-e DEFAULT_URI=http://localhost \
|
-e DEFAULT_URI=http://localhost \
|
||||||
-v ./var/share:/app/var/share \
|
-v ./var/share:/app/var/share \
|
||||||
-p 8000:80 \
|
-p 8000:80 \
|
||||||
digtialadapt/preauth:dev
|
digitaladapt/preauth:dev
|
||||||
|
|||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||||
+27
-16
@@ -4,22 +4,21 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.4",
|
"php": "^8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"bacon/bacon-qr-code": "^3.1.1",
|
"bacon/bacon-qr-code": "^3.1.1",
|
||||||
"runtime/frankenphp-symfony": "^1.0.0",
|
|
||||||
"spomky-labs/otphp": "^11.4.2",
|
"spomky-labs/otphp": "^11.4.2",
|
||||||
"symfony/cache": "7.4.*",
|
"symfony/cache": "8.1.*",
|
||||||
"symfony/console": "7.4.*",
|
"symfony/console": "8.1.*",
|
||||||
"symfony/flex": "^2.11",
|
"symfony/flex": "^2.11",
|
||||||
"symfony/framework-bundle": "7.4.*",
|
"symfony/framework-bundle": "8.1.*",
|
||||||
"symfony/mime": "7.4.*",
|
"symfony/mime": "8.1.*",
|
||||||
"symfony/rate-limiter": "7.4.*",
|
"symfony/rate-limiter": "8.1.*",
|
||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "8.1.*",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/twig-bundle": "8.1.*",
|
||||||
"symfony/uid": "7.4.*",
|
"symfony/uid": "8.1.*",
|
||||||
"symfony/yaml": "7.4.*"
|
"symfony/yaml": "8.1.*"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -28,13 +27,21 @@
|
|||||||
"symfony/runtime": true
|
"symfony/runtime": true
|
||||||
},
|
},
|
||||||
"bump-after-update": true,
|
"bump-after-update": true,
|
||||||
"sort-packages": true
|
"sort-packages": true,
|
||||||
|
"platform": {
|
||||||
|
"php": "8.5.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "src/"
|
"App\\": "src/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"replace": {
|
"replace": {
|
||||||
"symfony/polyfill-ctype": "*",
|
"symfony/polyfill-ctype": "*",
|
||||||
"symfony/polyfill-iconv": "*",
|
"symfony/polyfill-iconv": "*",
|
||||||
@@ -61,12 +68,16 @@
|
|||||||
"symfony/symfony": "*"
|
"symfony/symfony": "*"
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"runtime": {
|
|
||||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
|
||||||
},
|
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.4.*"
|
"require": "8.1.*"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.95",
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpunit/phpunit": "^13.2",
|
||||||
|
"symfony/browser-kit": "8.1.*",
|
||||||
|
"symfony/css-selector": "8.1.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4066
-583
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ framework:
|
|||||||
adapters: cache.adapter.apcu
|
adapters: cache.adapter.apcu
|
||||||
sessionStorage:
|
sessionStorage:
|
||||||
adapters: cache.adapter.filesystem
|
adapters: cache.adapter.filesystem
|
||||||
|
publicRateLimitCache:
|
||||||
|
adapters: cache.adapter.apcu
|
||||||
|
|
||||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||||
prefix_seed: digitaladapt/preauth
|
prefix_seed: digitaladapt/preauth
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ framework:
|
|||||||
trusted_proxies: 'private_ranges'
|
trusted_proxies: 'private_ranges'
|
||||||
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto']
|
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.
|
# Sessions are disabled — preauth implements its own cookie/cache-based
|
||||||
session: true
|
# session management and does not use Symfony's session subsystem.
|
||||||
|
session: false
|
||||||
|
|||||||
@@ -13,3 +13,17 @@ framework:
|
|||||||
login_limiter:
|
login_limiter:
|
||||||
policy: compound
|
policy: compound
|
||||||
limiters: [burst, upper]
|
limiters: [burst, upper]
|
||||||
|
|
||||||
|
public_burst:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: '%env(int:PUBLIC_BURST_COUNT)%'
|
||||||
|
interval: '%env(int:PUBLIC_BURST_TIME)% seconds'
|
||||||
|
cache_pool: 'publicRateLimitCache'
|
||||||
|
public_upper:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: '%env(int:PUBLIC_UPPER_COUNT)%'
|
||||||
|
interval: '%env(int:PUBLIC_UPPER_TIME)% seconds'
|
||||||
|
cache_pool: 'publicRateLimitCache'
|
||||||
|
public_limiter:
|
||||||
|
policy: compound
|
||||||
|
limiters: [public_burst, public_upper]
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
app: cache.adapter.array
|
||||||
|
pools:
|
||||||
|
nonceCache:
|
||||||
|
adapters: cache.adapter.array
|
||||||
|
rateLimitCache:
|
||||||
|
adapters: cache.adapter.array
|
||||||
|
sessionCache:
|
||||||
|
adapters: cache.adapter.array
|
||||||
|
sessionStorage:
|
||||||
|
adapters: cache.adapter.array
|
||||||
|
publicRateLimitCache:
|
||||||
|
adapters: cache.adapter.array
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
framework:
|
||||||
|
test: true
|
||||||
|
session:
|
||||||
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
if (file_exists(dirname(__DIR__) .
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (file_exists(dirname(__DIR__).
|
||||||
'/var/cache/prod/App_KernelProdContainer.preload.php')
|
'/var/cache/prod/App_KernelProdContainer.preload.php')
|
||||||
) {
|
) {
|
||||||
require dirname(__DIR__) .
|
require dirname(__DIR__).
|
||||||
'/var/cache/prod/App_KernelProdContainer.preload.php';
|
'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,844 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
|
||||||
|
|
||||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
|
||||||
|
|
||||||
use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>|Param|null>|Param|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
|
|
||||||
* @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|Param|null,
|
|
||||||
* http_method_override?: bool|Param, // 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|Param>|null,
|
|
||||||
* trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%"
|
|
||||||
* ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%"
|
|
||||||
* test?: bool|Param,
|
|
||||||
* default_locale?: scalar|Param|null, // Default: "en"
|
|
||||||
* set_locale_from_accept_language?: bool|Param, // 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|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false
|
|
||||||
* enabled_locales?: list<scalar|Param|null>,
|
|
||||||
* trusted_hosts?: list<scalar|Param|null>,
|
|
||||||
* trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"]
|
|
||||||
* trusted_headers?: list<scalar|Param|null>,
|
|
||||||
* error_controller?: scalar|Param|null, // Default: "error_controller"
|
|
||||||
* handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true
|
|
||||||
* csrf_protection?: bool|array{
|
|
||||||
* enabled?: scalar|Param|null, // Default: null
|
|
||||||
* stateless_token_ids?: list<scalar|Param|null>,
|
|
||||||
* check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false
|
|
||||||
* cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token"
|
|
||||||
* },
|
|
||||||
* form?: bool|array{ // Form configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* csrf_protection?: bool|array{
|
|
||||||
* enabled?: scalar|Param|null, // Default: null
|
|
||||||
* token_id?: scalar|Param|null, // Default: null
|
|
||||||
* field_name?: scalar|Param|null, // Default: "_token"
|
|
||||||
* field_attr?: array<string, scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* http_cache?: bool|array{ // HTTP cache configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
|
||||||
* trace_level?: "none"|"short"|"full"|Param,
|
|
||||||
* trace_header?: scalar|Param|null,
|
|
||||||
* default_ttl?: int|Param,
|
|
||||||
* private_headers?: list<scalar|Param|null>,
|
|
||||||
* skip_response_headers?: list<scalar|Param|null>,
|
|
||||||
* allow_reload?: bool|Param,
|
|
||||||
* allow_revalidate?: bool|Param,
|
|
||||||
* stale_while_revalidate?: int|Param,
|
|
||||||
* stale_if_error?: int|Param,
|
|
||||||
* terminate_on_cache_hit?: bool|Param,
|
|
||||||
* },
|
|
||||||
* esi?: bool|array{ // ESI configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* ssi?: bool|array{ // SSI configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* fragments?: bool|array{ // Fragments configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* hinclude_default_template?: scalar|Param|null, // Default: null
|
|
||||||
* path?: scalar|Param|null, // Default: "/_fragment"
|
|
||||||
* },
|
|
||||||
* profiler?: bool|array{ // Profiler configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* collect?: bool|Param, // Default: true
|
|
||||||
* collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null
|
|
||||||
* only_exceptions?: bool|Param, // Default: false
|
|
||||||
* only_main_requests?: bool|Param, // Default: false
|
|
||||||
* dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler"
|
|
||||||
* collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false
|
|
||||||
* },
|
|
||||||
* workflows?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* workflows?: array<string, array{ // Default: []
|
|
||||||
* audit_trail?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* type?: "workflow"|"state_machine"|Param, // Default: "state_machine"
|
|
||||||
* marking_store?: array{
|
|
||||||
* type?: "method"|Param,
|
|
||||||
* property?: scalar|Param|null,
|
|
||||||
* service?: scalar|Param|null,
|
|
||||||
* },
|
|
||||||
* supports?: list<scalar|Param|null>,
|
|
||||||
* definition_validators?: list<scalar|Param|null>,
|
|
||||||
* support_strategy?: scalar|Param|null,
|
|
||||||
* initial_marking?: list<scalar|Param|null>,
|
|
||||||
* events_to_dispatch?: list<string|Param>|null,
|
|
||||||
* places?: list<array{ // Default: []
|
|
||||||
* name?: scalar|Param|null,
|
|
||||||
* metadata?: array<string, mixed>,
|
|
||||||
* }>,
|
|
||||||
* transitions?: list<array{ // Default: []
|
|
||||||
* name?: string|Param,
|
|
||||||
* guard?: string|Param, // An expression to block the transition.
|
|
||||||
* from?: list<array{ // Default: []
|
|
||||||
* place?: string|Param,
|
|
||||||
* weight?: int|Param, // Default: 1
|
|
||||||
* }>,
|
|
||||||
* to?: list<array{ // Default: []
|
|
||||||
* place?: string|Param,
|
|
||||||
* weight?: int|Param, // Default: 1
|
|
||||||
* }>,
|
|
||||||
* weight?: int|Param, // Default: 1
|
|
||||||
* metadata?: array<string, mixed>,
|
|
||||||
* }>,
|
|
||||||
* metadata?: array<string, mixed>,
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* router?: bool|array{ // Router configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* resource?: scalar|Param|null,
|
|
||||||
* type?: scalar|Param|null,
|
|
||||||
* cache_dir?: scalar|Param|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|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
|
||||||
* http_port?: scalar|Param|null, // Default: 80
|
|
||||||
* https_port?: scalar|Param|null, // Default: 443
|
|
||||||
* strict_requirements?: scalar|Param|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|Param, // Default: true
|
|
||||||
* },
|
|
||||||
* session?: bool|array{ // Session configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native"
|
|
||||||
* handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null.
|
|
||||||
* name?: scalar|Param|null,
|
|
||||||
* cookie_lifetime?: scalar|Param|null,
|
|
||||||
* cookie_path?: scalar|Param|null,
|
|
||||||
* cookie_domain?: scalar|Param|null,
|
|
||||||
* cookie_secure?: true|false|"auto"|Param, // Default: "auto"
|
|
||||||
* cookie_httponly?: bool|Param, // Default: true
|
|
||||||
* cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
|
|
||||||
* use_cookies?: bool|Param,
|
|
||||||
* gc_divisor?: scalar|Param|null,
|
|
||||||
* gc_probability?: scalar|Param|null,
|
|
||||||
* gc_maxlifetime?: scalar|Param|null,
|
|
||||||
* save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null.
|
|
||||||
* metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0
|
|
||||||
* sid_length?: int|Param, // 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|Param, // 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|Param, // Default: false
|
|
||||||
* formats?: array<string, string|list<scalar|Param|null>>,
|
|
||||||
* },
|
|
||||||
* assets?: bool|array{ // Assets configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
|
||||||
* version_strategy?: scalar|Param|null, // Default: null
|
|
||||||
* version?: scalar|Param|null, // Default: null
|
|
||||||
* version_format?: scalar|Param|null, // Default: "%%s?%%s"
|
|
||||||
* json_manifest_path?: scalar|Param|null, // Default: null
|
|
||||||
* base_path?: scalar|Param|null, // Default: ""
|
|
||||||
* base_urls?: list<scalar|Param|null>,
|
|
||||||
* packages?: array<string, array{ // Default: []
|
|
||||||
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
|
||||||
* version_strategy?: scalar|Param|null, // Default: null
|
|
||||||
* version?: scalar|Param|null,
|
|
||||||
* version_format?: scalar|Param|null, // Default: null
|
|
||||||
* json_manifest_path?: scalar|Param|null, // Default: null
|
|
||||||
* base_path?: scalar|Param|null, // Default: ""
|
|
||||||
* base_urls?: list<scalar|Param|null>,
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* asset_mapper?: bool|array{ // Asset Mapper configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* paths?: array<string, scalar|Param|null>,
|
|
||||||
* excluded_patterns?: list<scalar|Param|null>,
|
|
||||||
* exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true
|
|
||||||
* server?: bool|Param, // 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|Param|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"|Param, // 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|Param|null>,
|
|
||||||
* importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php"
|
|
||||||
* importmap_polyfill?: scalar|Param|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|Param|null>,
|
|
||||||
* vendor_dir?: scalar|Param|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|Param, // Default: false
|
|
||||||
* formats?: list<scalar|Param|null>,
|
|
||||||
* extensions?: list<scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* translator?: bool|array{ // Translator configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* fallbacks?: list<scalar|Param|null>,
|
|
||||||
* logging?: bool|Param, // Default: false
|
|
||||||
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
|
||||||
* cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations"
|
|
||||||
* default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations"
|
|
||||||
* paths?: list<scalar|Param|null>,
|
|
||||||
* pseudo_localization?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* accents?: bool|Param, // Default: true
|
|
||||||
* expansion_factor?: float|Param, // Default: 1.0
|
|
||||||
* brackets?: bool|Param, // Default: true
|
|
||||||
* parse_html?: bool|Param, // Default: false
|
|
||||||
* localizable_html_attributes?: list<scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* providers?: array<string, array{ // Default: []
|
|
||||||
* dsn?: scalar|Param|null,
|
|
||||||
* domains?: list<scalar|Param|null>,
|
|
||||||
* locales?: list<scalar|Param|null>,
|
|
||||||
* }>,
|
|
||||||
* globals?: array<string, string|array{ // Default: []
|
|
||||||
* value?: mixed,
|
|
||||||
* message?: string|Param,
|
|
||||||
* parameters?: array<string, scalar|Param|null>,
|
|
||||||
* domain?: string|Param,
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* validation?: bool|array{ // Validation configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0.
|
|
||||||
* enable_attributes?: bool|Param, // Default: true
|
|
||||||
* static_method?: list<scalar|Param|null>,
|
|
||||||
* translation_domain?: scalar|Param|null, // Default: "validators"
|
|
||||||
* email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5"
|
|
||||||
* mapping?: array{
|
|
||||||
* paths?: list<scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* not_compromised_password?: bool|array{
|
|
||||||
* enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true
|
|
||||||
* endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null
|
|
||||||
* },
|
|
||||||
* disable_translation?: bool|Param, // Default: false
|
|
||||||
* auto_mapping?: array<string, array{ // Default: []
|
|
||||||
* services?: list<scalar|Param|null>,
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* annotations?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* serializer?: bool|array{ // Serializer configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* enable_attributes?: bool|Param, // Default: true
|
|
||||||
* name_converter?: scalar|Param|null,
|
|
||||||
* circular_reference_handler?: scalar|Param|null,
|
|
||||||
* max_depth_handler?: scalar|Param|null,
|
|
||||||
* mapping?: array{
|
|
||||||
* paths?: list<scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* default_context?: array<string, mixed>,
|
|
||||||
* named_serializers?: array<string, array{ // Default: []
|
|
||||||
* name_converter?: scalar|Param|null,
|
|
||||||
* default_context?: array<string, mixed>,
|
|
||||||
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
|
|
||||||
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* property_access?: bool|array{ // Property access configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* magic_call?: bool|Param, // Default: false
|
|
||||||
* magic_get?: bool|Param, // Default: true
|
|
||||||
* magic_set?: bool|Param, // Default: true
|
|
||||||
* throw_exception_on_invalid_index?: bool|Param, // Default: false
|
|
||||||
* throw_exception_on_invalid_property_path?: bool|Param, // Default: true
|
|
||||||
* },
|
|
||||||
* type_info?: bool|array{ // Type info configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* aliases?: array<string, scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* property_info?: bool|array{ // Property info configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* with_constructor_extractor?: bool|Param, // Registers the constructor extractor.
|
|
||||||
* },
|
|
||||||
* cache?: array{ // Cache configuration
|
|
||||||
* prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%"
|
|
||||||
* app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem"
|
|
||||||
* system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system"
|
|
||||||
* directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app"
|
|
||||||
* default_psr6_provider?: scalar|Param|null,
|
|
||||||
* default_redis_provider?: scalar|Param|null, // Default: "redis://localhost"
|
|
||||||
* default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost"
|
|
||||||
* default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost"
|
|
||||||
* default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection"
|
|
||||||
* default_pdo_provider?: scalar|Param|null, // Default: null
|
|
||||||
* pools?: array<string, array{ // Default: []
|
|
||||||
* adapters?: list<scalar|Param|null>,
|
|
||||||
* tags?: scalar|Param|null, // Default: null
|
|
||||||
* public?: bool|Param, // Default: false
|
|
||||||
* default_lifetime?: scalar|Param|null, // Default lifetime of the pool.
|
|
||||||
* provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter.
|
|
||||||
* early_expiration_message_bus?: scalar|Param|null,
|
|
||||||
* clearer?: scalar|Param|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|Param, // Throw PHP errors as \ErrorException instances. // Default: true
|
|
||||||
* },
|
|
||||||
* exceptions?: array<string, array{ // Default: []
|
|
||||||
* log_level?: scalar|Param|null, // The level of log message. Null to let Symfony decide. // Default: null
|
|
||||||
* status_code?: scalar|Param|null, // The status code of the response. Null or 0 to let Symfony decide. // Default: null
|
|
||||||
* log_channel?: scalar|Param|null, // The channel of log message. Null to let Symfony decide. // Default: null
|
|
||||||
* }>,
|
|
||||||
* web_link?: bool|array{ // Web links configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* lock?: bool|string|array{ // Lock configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* resources?: array<string, string|list<scalar|Param|null>>,
|
|
||||||
* },
|
|
||||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* resources?: array<string, scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* messenger?: bool|array{ // Messenger configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* routing?: array<string, string|array{ // Default: []
|
|
||||||
* senders?: list<scalar|Param|null>,
|
|
||||||
* }>,
|
|
||||||
* serializer?: array{
|
|
||||||
* default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer"
|
|
||||||
* symfony_serializer?: array{
|
|
||||||
* format?: scalar|Param|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|Param|null,
|
|
||||||
* serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null
|
|
||||||
* options?: array<string, mixed>,
|
|
||||||
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
|
||||||
* retry_strategy?: string|array{
|
|
||||||
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
|
|
||||||
* max_retries?: int|Param, // Default: 3
|
|
||||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
|
||||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2
|
|
||||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
|
||||||
* jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1
|
|
||||||
* },
|
|
||||||
* rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null
|
|
||||||
* }>,
|
|
||||||
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
|
||||||
* stop_worker_on_signals?: list<scalar|Param|null>,
|
|
||||||
* default_bus?: scalar|Param|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|Param, // Default: true
|
|
||||||
* allow_no_handlers?: bool|Param, // Default: false
|
|
||||||
* allow_no_senders?: bool|Param, // Default: true
|
|
||||||
* },
|
|
||||||
* middleware?: list<string|array{ // Default: []
|
|
||||||
* id?: scalar|Param|null,
|
|
||||||
* arguments?: list<mixed>,
|
|
||||||
* }>,
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* scheduler?: bool|array{ // Scheduler configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
|
||||||
* http_client?: bool|array{ // HTTP Client configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
|
||||||
* default_options?: array{
|
|
||||||
* headers?: array<string, mixed>,
|
|
||||||
* vars?: array<string, mixed>,
|
|
||||||
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
|
||||||
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
|
||||||
* resolve?: array<string, scalar|Param|null>,
|
|
||||||
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
|
||||||
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
|
||||||
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
|
||||||
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
|
||||||
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
|
||||||
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
|
||||||
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
|
||||||
* cafile?: scalar|Param|null, // A certificate authority file.
|
|
||||||
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
|
|
||||||
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
|
|
||||||
* local_pk?: scalar|Param|null, // A private key file.
|
|
||||||
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
|
|
||||||
* ciphers?: scalar|Param|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|Param|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|Param|null, // Rate limiter name to use for throttling requests. // Default: null
|
|
||||||
* caching?: bool|array{ // Caching configuration.
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
|
||||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
|
||||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
|
||||||
* },
|
|
||||||
* retry_failed?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
|
|
||||||
* http_codes?: array<string, array{ // Default: []
|
|
||||||
* code?: int|Param,
|
|
||||||
* methods?: list<string|Param>,
|
|
||||||
* }>,
|
|
||||||
* max_retries?: int|Param, // Default: 3
|
|
||||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
|
||||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
|
||||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
|
||||||
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* mock_response_factory?: scalar|Param|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|Param|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|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.
|
|
||||||
* auth_basic?: scalar|Param|null, // An HTTP Basic authentication "username:password".
|
|
||||||
* auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization.
|
|
||||||
* auth_ntlm?: scalar|Param|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension).
|
|
||||||
* query?: array<string, scalar|Param|null>,
|
|
||||||
* headers?: array<string, mixed>,
|
|
||||||
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
|
||||||
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
|
||||||
* resolve?: array<string, scalar|Param|null>,
|
|
||||||
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
|
|
||||||
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
|
|
||||||
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
|
||||||
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
|
||||||
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
|
||||||
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
|
||||||
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
|
||||||
* cafile?: scalar|Param|null, // A certificate authority file.
|
|
||||||
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
|
|
||||||
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
|
|
||||||
* local_pk?: scalar|Param|null, // A private key file.
|
|
||||||
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
|
|
||||||
* ciphers?: scalar|Param|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|Param|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|Param|null, // Rate limiter name to use for throttling requests. // Default: null
|
|
||||||
* caching?: bool|array{ // Caching configuration.
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
|
||||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
|
||||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
|
||||||
* },
|
|
||||||
* retry_failed?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
|
|
||||||
* http_codes?: array<string, array{ // Default: []
|
|
||||||
* code?: int|Param,
|
|
||||||
* methods?: list<string|Param>,
|
|
||||||
* }>,
|
|
||||||
* max_retries?: int|Param, // Default: 3
|
|
||||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
|
||||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
|
||||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
|
||||||
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
|
||||||
* },
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* mailer?: bool|array{ // Mailer configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
|
||||||
* dsn?: scalar|Param|null, // Default: null
|
|
||||||
* transports?: array<string, scalar|Param|null>,
|
|
||||||
* envelope?: array{ // Mailer Envelope configuration
|
|
||||||
* sender?: scalar|Param|null,
|
|
||||||
* recipients?: list<scalar|Param|null>,
|
|
||||||
* allowed_recipients?: list<scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* headers?: array<string, string|array{ // Default: []
|
|
||||||
* value?: mixed,
|
|
||||||
* }>,
|
|
||||||
* dkim_signer?: bool|array{ // DKIM signer configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: ""
|
|
||||||
* domain?: scalar|Param|null, // Default: ""
|
|
||||||
* select?: scalar|Param|null, // Default: ""
|
|
||||||
* passphrase?: scalar|Param|null, // The private key passphrase // Default: ""
|
|
||||||
* options?: array<string, mixed>,
|
|
||||||
* },
|
|
||||||
* smime_signer?: bool|array{ // S/MIME signer configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* key?: scalar|Param|null, // Path to key (in PEM format) // Default: ""
|
|
||||||
* certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: ""
|
|
||||||
* passphrase?: scalar|Param|null, // The private key passphrase // Default: null
|
|
||||||
* extra_certificates?: scalar|Param|null, // Default: null
|
|
||||||
* sign_options?: int|Param, // Default: null
|
|
||||||
* },
|
|
||||||
* smime_encrypter?: bool|array{ // S/MIME encrypter configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: ""
|
|
||||||
* cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* secrets?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%"
|
|
||||||
* local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local"
|
|
||||||
* decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET"
|
|
||||||
* },
|
|
||||||
* notifier?: bool|array{ // Notifier configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
|
||||||
* chatter_transports?: array<string, scalar|Param|null>,
|
|
||||||
* texter_transports?: array<string, scalar|Param|null>,
|
|
||||||
* notification_on_failed_messages?: bool|Param, // Default: false
|
|
||||||
* channel_policy?: array<string, string|list<scalar|Param|null>>,
|
|
||||||
* admin_recipients?: list<array{ // Default: []
|
|
||||||
* email?: scalar|Param|null,
|
|
||||||
* phone?: scalar|Param|null, // Default: ""
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* limiters?: array<string, array{ // Default: []
|
|
||||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
|
||||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
|
||||||
* storage_service?: scalar|Param|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"|Param, // The algorithm to be used by this limiter.
|
|
||||||
* limiters?: list<scalar|Param|null>,
|
|
||||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
|
||||||
* interval?: scalar|Param|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|Param|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|Param, // Amount of tokens to add each interval. // Default: 1
|
|
||||||
* },
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* uid?: bool|array{ // Uid configuration
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* default_uuid_version?: 7|6|4|1|Param, // Default: 7
|
|
||||||
* name_based_uuid_version?: 5|3|Param, // Default: 5
|
|
||||||
* name_based_uuid_namespace?: scalar|Param|null,
|
|
||||||
* time_based_uuid_version?: 7|6|1|Param, // Default: 7
|
|
||||||
* time_based_uuid_node?: scalar|Param|null,
|
|
||||||
* },
|
|
||||||
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* sanitizers?: array<string, array{ // Default: []
|
|
||||||
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
|
|
||||||
* allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
|
|
||||||
* allow_elements?: array<string, mixed>,
|
|
||||||
* block_elements?: list<string|Param>,
|
|
||||||
* drop_elements?: list<string|Param>,
|
|
||||||
* allow_attributes?: array<string, mixed>,
|
|
||||||
* drop_attributes?: array<string, mixed>,
|
|
||||||
* force_attributes?: array<string, array<string, string|Param>>,
|
|
||||||
* force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false
|
|
||||||
* allowed_link_schemes?: list<string|Param>,
|
|
||||||
* allowed_link_hosts?: list<string|Param>|null,
|
|
||||||
* allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false
|
|
||||||
* allowed_media_schemes?: list<string|Param>,
|
|
||||||
* allowed_media_hosts?: list<string|Param>|null,
|
|
||||||
* allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false
|
|
||||||
* with_attribute_sanitizers?: list<string|Param>,
|
|
||||||
* without_attribute_sanitizers?: list<string|Param>,
|
|
||||||
* max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* webhook?: bool|array{ // Webhook configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
|
||||||
* routing?: array<string, array{ // Default: []
|
|
||||||
* service?: scalar|Param|null,
|
|
||||||
* secret?: scalar|Param|null, // Default: ""
|
|
||||||
* }>,
|
|
||||||
* },
|
|
||||||
* remote-event?: bool|array{ // RemoteEvent configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* json_streamer?: bool|array{ // JSON streamer configuration
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* }
|
|
||||||
* @psalm-type TwigConfig = array{
|
|
||||||
* form_themes?: list<scalar|Param|null>,
|
|
||||||
* globals?: array<string, array{ // Default: []
|
|
||||||
* id?: scalar|Param|null,
|
|
||||||
* type?: scalar|Param|null,
|
|
||||||
* value?: mixed,
|
|
||||||
* }>,
|
|
||||||
* autoescape_service?: scalar|Param|null, // Default: null
|
|
||||||
* autoescape_service_method?: scalar|Param|null, // Default: null
|
|
||||||
* base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated.
|
|
||||||
* cache?: scalar|Param|null, // Default: true
|
|
||||||
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
|
|
||||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
|
||||||
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
|
|
||||||
* auto_reload?: scalar|Param|null,
|
|
||||||
* optimizations?: int|Param,
|
|
||||||
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
|
|
||||||
* file_name_pattern?: list<scalar|Param|null>,
|
|
||||||
* paths?: array<string, mixed>,
|
|
||||||
* date?: array{ // The default format options used by the date filter.
|
|
||||||
* format?: scalar|Param|null, // Default: "F j, Y H:i"
|
|
||||||
* interval_format?: scalar|Param|null, // Default: "%d days"
|
|
||||||
* timezone?: scalar|Param|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|Param, // Default: 0
|
|
||||||
* decimal_point?: scalar|Param|null, // Default: "."
|
|
||||||
* thousands_separator?: scalar|Param|null, // Default: ","
|
|
||||||
* },
|
|
||||||
* mailer?: array{
|
|
||||||
* html_to_text_converter?: scalar|Param|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,
|
|
||||||
* },
|
|
||||||
* ...<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
|
|
||||||
{
|
|
||||||
/** @var ConfigType $config */
|
|
||||||
$config = AppReference::config($config);
|
|
||||||
|
|
||||||
return $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>,
|
|
||||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
final class Routes
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param RoutesConfig $config
|
|
||||||
*
|
|
||||||
* @psalm-return RoutesConfig
|
|
||||||
*/
|
|
||||||
public static function config(array $config): array
|
|
||||||
{
|
|
||||||
return $config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+34
-4
@@ -27,6 +27,16 @@ parameters:
|
|||||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||||
env(TEAPOT): '1' # boolean
|
env(TEAPOT): '1' # boolean
|
||||||
|
|
||||||
|
# --- remote-user header ---
|
||||||
|
# Controls the value sent in the Remote-User header on successful auth.
|
||||||
|
# session: the session id (default, backward-compatible)
|
||||||
|
# static: a fixed string (set via REMOTE_USER_STATIC)
|
||||||
|
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
|
||||||
|
# none: do not send the Remote-User header at all
|
||||||
|
env(REMOTE_USER): 'session'
|
||||||
|
env(REMOTE_USER_STATIC): 'authenticated'
|
||||||
|
env(REMOTE_USER_MAP): ''
|
||||||
|
|
||||||
# --- rate limiting ---
|
# --- rate limiting ---
|
||||||
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
||||||
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
|
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
|
||||||
@@ -35,6 +45,16 @@ parameters:
|
|||||||
env(UPPER_COUNT): 10 # 10 per hour
|
env(UPPER_COUNT): 10 # 10 per hour
|
||||||
env(UPPER_TIME): 3600 # seconds (1 hour)
|
env(UPPER_TIME): 3600 # seconds (1 hour)
|
||||||
|
|
||||||
|
# --- public access (rate-limited, no auth required) ---
|
||||||
|
# Comma-separated path patterns for public access. Wildcards: * (single
|
||||||
|
# segment), ** (cross segments). Optional host prefix: host.com/path/**
|
||||||
|
# When empty (default), the feature is fully disabled.
|
||||||
|
env(PUBLIC_PATHS): ''
|
||||||
|
env(PUBLIC_BURST_COUNT): 100 # max requests per burst window per IP
|
||||||
|
env(PUBLIC_BURST_TIME): 60 # burst window in seconds
|
||||||
|
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
|
||||||
|
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
|
||||||
|
|
||||||
# --- styling options ---
|
# --- styling options ---
|
||||||
env(TITLE): 'Pre-Authentication System'
|
env(TITLE): 'Pre-Authentication System'
|
||||||
env(BG_COLOR): '#029386' # teal
|
env(BG_COLOR): '#029386' # teal
|
||||||
@@ -56,12 +76,22 @@ parameters:
|
|||||||
|
|
||||||
# --- application variables ---
|
# --- application variables ---
|
||||||
app.totp_uri: '%env(TOTP_URI)%'
|
app.totp_uri: '%env(TOTP_URI)%'
|
||||||
app.cookie_ttl: '%env(COOKIE_TTL)%'
|
app.cookie_ttl: '%env(int:COOKIE_TTL)%'
|
||||||
app.subdomain_redirect: '%env(SUBDOMAIN_REDIRECT)%'
|
app.subdomain_redirect: '%env(bool:SUBDOMAIN_REDIRECT)%'
|
||||||
app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%'
|
app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%'
|
||||||
|
|
||||||
app.ip_ttl: '%env(IP_TTL)%'
|
app.ip_ttl: '%env(int:IP_TTL)%'
|
||||||
app.teapot: '%env(TEAPOT)%'
|
app.teapot: '%env(bool:TEAPOT)%'
|
||||||
|
|
||||||
|
app.remote_user: '%env(REMOTE_USER)%'
|
||||||
|
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
|
||||||
|
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
|
||||||
|
|
||||||
|
app.public_paths: '%env(PUBLIC_PATHS)%'
|
||||||
|
app.public_burst_count: '%env(int:PUBLIC_BURST_COUNT)%'
|
||||||
|
app.public_burst_time: '%env(int:PUBLIC_BURST_TIME)%'
|
||||||
|
app.public_upper_count: '%env(int:PUBLIC_UPPER_COUNT)%'
|
||||||
|
app.public_upper_time: '%env(int:PUBLIC_UPPER_TIME)%'
|
||||||
|
|
||||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Preauth build config
|
||||||
|
#
|
||||||
|
# CI (develop.yaml / docker.yaml) invokes this with --file so the compose
|
||||||
|
# file that lives in the same directory is not merged in as extra targets.
|
||||||
|
#
|
||||||
|
# DOCKERHUB_TARGET is the org/repo (Gitea Settings → Variables)
|
||||||
|
# CI sets TAG=latest + VERSION=<v-stripped> for tag pushes,
|
||||||
|
# TAG=develop for pushes to main.
|
||||||
|
#
|
||||||
|
# MAX_REQUESTS=0 docker buildx bake # specify variables to override
|
||||||
|
|
||||||
|
variable "DOCKERHUB_TARGET" {
|
||||||
|
default = "digitaladapt/preauth"
|
||||||
|
description = "Docker Hub repo/org (Gitea repo variable DOCKERHUB_TARGET)."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "TAG" {
|
||||||
|
default = "latest"
|
||||||
|
description = "Base tag for this build: latest (release), develop (main push), or a version."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "VERSION" {
|
||||||
|
default = ""
|
||||||
|
description = "Full version (v stripped) to also tag with; empty for develop builds."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "MAX_REQUESTS" {
|
||||||
|
default = "500"
|
||||||
|
description = "Restart each FrankenPHP worker thread after N requests (0 disables). Baked in at build time; the same env var overrides it at runtime."
|
||||||
|
}
|
||||||
|
|
||||||
|
group "default" {
|
||||||
|
targets = ["app"]
|
||||||
|
}
|
||||||
|
|
||||||
|
target "app" {
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
target = "app"
|
||||||
|
context = "."
|
||||||
|
platforms = ["linux/amd64", "linux/arm64"]
|
||||||
|
|
||||||
|
# Layer cache. The shared docker-publish.yaml sets cache-from/cache-to for
|
||||||
|
# its `action` backend but NOT for `bake`, so specifying it here is what keeps
|
||||||
|
# CI builds warm. preauth compiles APCu from source (pecl) in both stages, so
|
||||||
|
# a cold build is expensive.
|
||||||
|
#
|
||||||
|
# Local builds outside CI have no GHA cache service, so override:
|
||||||
|
# docker buildx bake --set 'app.cache-to=' --set 'app.cache-from='
|
||||||
|
cache-from = ["type=gha"]
|
||||||
|
cache-to = ["type=gha,mode=max"]
|
||||||
|
|
||||||
|
# The Dockerfile declares ARG MAX_REQUESTS=500 for plain `docker build`.
|
||||||
|
# It is repeated explicitly here so CI's value is visible and can be changed
|
||||||
|
# in this file instead of in a workflow. Keep the two defaults in sync.
|
||||||
|
args = {
|
||||||
|
MAX_REQUESTS = "${MAX_REQUESTS}"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = concat(
|
||||||
|
["${DOCKERHUB_TARGET}:${TAG}"],
|
||||||
|
VERSION != "" ? ["${DOCKERHUB_TARGET}:${VERSION}"] : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# example of securing full service
|
|
||||||
# TODO replace domain and service name and port
|
|
||||||
service.example.com {
|
|
||||||
forward_auth preauth {
|
|
||||||
uri {uri}
|
|
||||||
copy_headers Remote-User
|
|
||||||
}
|
|
||||||
reverse_proxy service-container:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# you can choose to only restrict select paths
|
|
||||||
# or any other Caddy match criteria, if desired
|
|
||||||
# IE: https://protected.example.com/secure/
|
|
||||||
protected.example.com {
|
|
||||||
# note any request that does not start with "/secure/" is NOT protected
|
|
||||||
forward_auth /secure/* preauth {
|
|
||||||
uri {uri}
|
|
||||||
copy_headers Remote-User
|
|
||||||
}
|
|
||||||
reverse_proxy protected-service:9000
|
|
||||||
}
|
|
||||||
|
|
||||||
# optionally, if you want to use a subdomain for centeral preauth
|
|
||||||
# set SUBDOMAIN_REDIRECT to true
|
|
||||||
# and AUTH_SUBDOMAIN to match the subdomain you use here
|
|
||||||
auth.example.com {
|
|
||||||
reverse_proxy preauth
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,24 @@
|
|||||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||||
#TEAPOT=true # default enabled, boolean
|
#TEAPOT=true # default enabled, boolean
|
||||||
|
|
||||||
|
# --- server / worker options ---
|
||||||
|
|
||||||
|
# (container/deployment only) restart each FrankenPHP worker thread after
|
||||||
|
# this many requests, containing memory growth across long uptime;
|
||||||
|
# matching the default from the old runtime/frankenphp-symfony package.
|
||||||
|
# 0 disables restarts. consumed by the Caddyfile, not the PHP app.
|
||||||
|
#MAX_REQUESTS=500 # default 500
|
||||||
|
|
||||||
|
# --- remote-user header ---
|
||||||
|
# Controls the value sent in the Remote-User header on successful auth.
|
||||||
|
# session: the session id (default, backward-compatible)
|
||||||
|
# static: a fixed string (set via REMOTE_USER_STATIC)
|
||||||
|
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
|
||||||
|
# none: do not send the Remote-User header at all
|
||||||
|
#REMOTE_USER=session
|
||||||
|
#REMOTE_USER_STATIC=authenticated
|
||||||
|
#REMOTE_USER_MAP=''
|
||||||
|
|
||||||
# --- rate limiting ---
|
# --- rate limiting ---
|
||||||
|
|
||||||
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
||||||
@@ -33,6 +51,18 @@
|
|||||||
#UPPER_COUNT=10 # 10 per hour
|
#UPPER_COUNT=10 # 10 per hour
|
||||||
#UPPER_TIME=3600 # seconds (1 hour)
|
#UPPER_TIME=3600 # seconds (1 hour)
|
||||||
|
|
||||||
|
# --- public access (rate-limited, no auth required) ---
|
||||||
|
# Comma-separated path patterns for public access. Wildcards:
|
||||||
|
# * matches any chars within one path segment (not crossing /)
|
||||||
|
# ** matches any chars including / (crosses path segments)
|
||||||
|
# Optional host prefix: host.example.com/path/**
|
||||||
|
# When empty (default), the feature is fully disabled.
|
||||||
|
#PUBLIC_PATHS=''
|
||||||
|
#PUBLIC_BURST_COUNT=100 # max requests per burst window per IP
|
||||||
|
#PUBLIC_BURST_TIME=60 # burst window in seconds
|
||||||
|
#PUBLIC_UPPER_COUNT=500 # max requests per sustained window per IP
|
||||||
|
#PUBLIC_UPPER_TIME=3600 # sustained window in seconds (1 hour)
|
||||||
|
|
||||||
# --- styling options ---
|
# --- styling options ---
|
||||||
|
|
||||||
#TITLE='Pre-Authentication System'
|
#TITLE='Pre-Authentication System'
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# preauth example Caddyfile
|
||||||
|
|
||||||
|
# --- anti-caching guard for the login flow ---
|
||||||
|
# The login page, failed logins, redirects, and rate-limit pages must never
|
||||||
|
# be stored or replayed by a browser or intermediate cache. If they are,
|
||||||
|
# an aggressive cache (notably older Safari) can resurrect a stale pre-auth
|
||||||
|
# response — appearing to log a user back out after a refresh. preauth
|
||||||
|
# sends these headers itself; mirroring them here with `header_down` keeps
|
||||||
|
# the guarantee at the edge. Import this snippet inside every `forward_auth`
|
||||||
|
# block:
|
||||||
|
#
|
||||||
|
# forward_auth preauth { ...; import preauth_no_store }
|
||||||
|
#
|
||||||
|
# Note: 2xx auth responses are consumed by Caddy's forward_auth check and
|
||||||
|
# never reach the browser, and the protected service's own responses are
|
||||||
|
# not affected — so the cache headers of your services are left alone.
|
||||||
|
(preauth_no_store) {
|
||||||
|
header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"
|
||||||
|
header_down Pragma "no-cache"
|
||||||
|
header_down Expires "0"
|
||||||
|
header_down Surrogate-Control "no-store"
|
||||||
|
header_down Vary "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# example of securing full service
|
||||||
|
# TODO replace domain and service name and port
|
||||||
|
service.example.com {
|
||||||
|
forward_auth preauth {
|
||||||
|
uri {uri}
|
||||||
|
copy_headers Remote-User
|
||||||
|
import preauth_no_store
|
||||||
|
}
|
||||||
|
reverse_proxy service-container:80
|
||||||
|
}
|
||||||
|
|
||||||
|
# you can choose to only restrict select paths
|
||||||
|
# or any other Caddy match criteria, if desired
|
||||||
|
# IE: https://protected.example.com/secure/
|
||||||
|
protected.example.com {
|
||||||
|
# note any request that does not start with "/secure/" is NOT protected
|
||||||
|
forward_auth /secure/* preauth {
|
||||||
|
uri {uri}
|
||||||
|
copy_headers Remote-User
|
||||||
|
import preauth_no_store
|
||||||
|
}
|
||||||
|
reverse_proxy protected-service:9000
|
||||||
|
}
|
||||||
|
|
||||||
|
# optionally, if you want to use a subdomain for central preauth
|
||||||
|
# set SUBDOMAIN_REDIRECT to true
|
||||||
|
# and AUTH_SUBDOMAIN to match the subdomain you use here
|
||||||
|
auth.example.com {
|
||||||
|
reverse_proxy preauth
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- public rate-limited access (v1.1) ---
|
||||||
|
# Configure PUBLIC_PATHS env var to specify which paths are public.
|
||||||
|
# Example: PUBLIC_PATHS=/public/**
|
||||||
|
# Unauthenticated visitors to public paths are rate-limited separately
|
||||||
|
# from login attempts. Authenticated users bypass the public rate limiter.
|
||||||
|
#
|
||||||
|
# This example protects all of Gitea except /public/** which is
|
||||||
|
# publicly accessible but rate-limited (e.g., 100 req/min, 500 req/hr).
|
||||||
|
git.example.com {
|
||||||
|
forward_auth preauth {
|
||||||
|
uri {uri}
|
||||||
|
copy_headers Remote-User
|
||||||
|
import preauth_no_store
|
||||||
|
}
|
||||||
|
reverse_proxy gitea:3000
|
||||||
|
}
|
||||||
|
# In preauth's .env:
|
||||||
|
# PUBLIC_PATHS=/public/**
|
||||||
|
# PUBLIC_BURST_COUNT=100
|
||||||
|
# PUBLIC_BURST_TIME=60
|
||||||
|
# PUBLIC_UPPER_COUNT=500
|
||||||
|
# PUBLIC_UPPER_TIME=3600
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
preauth:
|
preauth:
|
||||||
env_file:
|
env_file:
|
||||||
# TODO rename "example.env" to ".env", edit as needed
|
# copy ".env.example" to ".env", edit as needed
|
||||||
# strongly recommend setting TOTP_URI, if not provided the app
|
# strongly recommend setting TOTP_URI, if not provided the app
|
||||||
# will generate one for you, please copy it into your .env file
|
# will generate one for you, please copy it into your .env file
|
||||||
- .env
|
- .env
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Upgrade Plan: Symfony 7.4 → 8.1
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented on branch `feat/symfony-8.1-upgrade-plan`
|
||||||
|
(Phases 0–3 & state audit complete; Phases 4–5 = staging + release)
|
||||||
|
**Target:** Symfony `8.1.*` (all symfony components)
|
||||||
|
**Was:** Symfony `7.4.*` → **resolved 8.1.2–8.1.6**
|
||||||
|
**Prepared:** 2026-09-07
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation results
|
||||||
|
|
||||||
|
| Phase | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| 0 Deprecation sweep | ✅ Clean — suite runs with `failOnDeprecation=true`, zero hits on 7.4; the 8.x jump needed **no app code changes**. |
|
||||||
|
| 1 Composer bump | ✅ `runtime/frankenphp-symfony` removed, `extra.runtime` deleted, all `symfony/*` at `8.1.*` (framework-bundle 8.1.6, twig-bundle 8.1.2); ride-alongs PHPUnit 13.3.2, Twig 3.28, otphp 11.5. Boots on **v8.1.6**. |
|
||||||
|
| 2 Config refresh | ✅ `config/reference.php` is gitignored, auto-regenerated by Flex. Prod `cache:clear`+`cache:warmup`, `lint:container`/`lint:yaml`/`lint:twig` all pass. |
|
||||||
|
| 3 Tests | ✅ **295 tests / 612 assertions green** on 8.1; php-cs-fixer 0 fixable files. |
|
||||||
|
| State audit | ✅ All `src/` services are `final readonly` with ctor-injected deps — no mutable state, kernel reuse under `FrankenPhpWorkerRunner` is safe. |
|
||||||
|
| Loop-max parity | ✅ `Caddyfile` sets `max_requests {$MAX_REQUESTS}`; default **500** baked into the image via Docker build arg (matches old package default), runtime-overridable. See §2 note. |
|
||||||
|
|
||||||
|
Phases 4–5 (staging smoke + release) are pending — everything else in
|
||||||
|
this document describes what was planned **and is now done**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why we can leapfrog 8.0
|
||||||
|
|
||||||
|
Symfony 7.4 and 8.0 were released simultaneously (Nov 2025) and are
|
||||||
|
feature-identical — 8.0 is simply 7.4 with the deprecated code removed.
|
||||||
|
Because preauth is **already on 7.4**, we are on the last LTS bridge
|
||||||
|
release. The only gating question for 8.x is whether we still trigger
|
||||||
|
any deprecations. If `composer test` runs clean under 7.4 with
|
||||||
|
`SYMFONY_DEPRECATIONS_HELPER` strict, upgrading straight to 8.1 is safe
|
||||||
|
and avoids a double-bump of `composer.json` / `composer.lock`.
|
||||||
|
|
||||||
|
Symfony 8.1 (May 2026 cycle) also brings a runtime improvement we
|
||||||
|
directly benefit from (see §3).
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- ✅ PHP: Symfony 8.x requires PHP **>= 8.4**; composer.json already
|
||||||
|
requires `>= 8.4`, Docker and CI run 8.5. No PHP work needed.
|
||||||
|
- ⚠️ Deprecations: must be inventoried and fixed before the version bump
|
||||||
|
(see Phase 0).
|
||||||
|
|
||||||
|
## 2. The `runtime/frankenphp-symfony` removal
|
||||||
|
|
||||||
|
We currently use the community runtime package for FrankenPHP worker
|
||||||
|
mode, wired in two places in `composer.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"require": {
|
||||||
|
"runtime/frankenphp-symfony": "^1.0.0",
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"runtime": {
|
||||||
|
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As of Symfony 7.4+, `symfony/runtime` ships its own
|
||||||
|
`Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner`, and **in 8.1
|
||||||
|
the runtime handles FrankenPHP worker mode natively** (including new
|
||||||
|
8.1 support for returning a `Response` from worker mode). The
|
||||||
|
community package is redundant.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
|
||||||
|
1. `composer remove runtime/frankenphp-symfony` (as part of the 8.1 bump
|
||||||
|
in §4 — do it in the same `composer update` to keep one lockfile diff).
|
||||||
|
2. Delete the entire `extra.runtime` block from `composer.json` so the
|
||||||
|
default `Symfony\Component\Runtime\GenericRuntime` is used; the
|
||||||
|
built-in `FrankenPhpWorkerRunner` is auto-selected when
|
||||||
|
`frankenphp_handle_request()` exists (i.e. inside FrankenPHP worker
|
||||||
|
mode). Falling back to plain `APP_RUNTIME=Symfony\...\Runtime` env
|
||||||
|
override is possible but should not be needed.
|
||||||
|
3. Verify `symfony.lock` — Flex should drop the
|
||||||
|
`runtime/frankenphp-symfony` entry automatically on removal.
|
||||||
|
4. `public/index.php` needs **no change** — it already just returns the
|
||||||
|
Kernel closure via `autoload_runtime.php`.
|
||||||
|
|
||||||
|
**Note on loop_max:** the old package exposed
|
||||||
|
`FRANKENPHP_LOOP_MAX` (default 500). The built-in runner does not
|
||||||
|
read that env var. We never set it, so behavior is unchanged — but
|
||||||
|
check staging memory usage under worker mode and, if ever needed,
|
||||||
|
control restarts via FrankenPHP's own `worker ... num N` / max-requests
|
||||||
|
options in the Caddyfile instead.
|
||||||
|
|
||||||
|
## 3. composer.json changes
|
||||||
|
|
||||||
|
### `require`
|
||||||
|
|
||||||
|
| Package | From | To |
|
||||||
|
|--------------------------|------------|---------|
|
||||||
|
| `symfony/cache` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/console` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/framework-bundle`| `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/mime` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/rate-limiter` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/runtime` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/twig-bundle` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/uid` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/yaml` | `7.4.*` | `8.1.*` |
|
||||||
|
| ~~`runtime/frankenphp-symfony`~~ | `^1.0.0` | **removed** |
|
||||||
|
|
||||||
|
`symfony/flex` (`^2.11`), `bacon/bacon-qr-code` (^3) and
|
||||||
|
`spomky-labs/otphp` (^11) are compatible with 8.x — no change expected,
|
||||||
|
but let composer confirm during the update.
|
||||||
|
|
||||||
|
### `require-dev`
|
||||||
|
|
||||||
|
| Package | From | To |
|
||||||
|
|--------------------------|---------|---------|
|
||||||
|
| `symfony/browser-kit` | `7.4.*` | `8.1.*` |
|
||||||
|
| `symfony/css-selector` | `7.4.*` | `8.1.*` |
|
||||||
|
|
||||||
|
`phpunit/phpunit ^13.2` and `friendsofphp/php-cs-fixer` already support
|
||||||
|
PHP 8.5 / Symfony 8.
|
||||||
|
|
||||||
|
### `extra`
|
||||||
|
|
||||||
|
```diff
|
||||||
|
"extra": {
|
||||||
|
- "runtime": {
|
||||||
|
- "class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||||
|
- },
|
||||||
|
"symfony": {
|
||||||
|
"allow-contrib": false,
|
||||||
|
- "require": "7.4.*"
|
||||||
|
+ "require": "8.1.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### One-shot command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer update \
|
||||||
|
"symfony/*" \
|
||||||
|
--with-all-dependencies
|
||||||
|
# plus explicit remove of runtime/frankenphp-symfony beforehand
|
||||||
|
```
|
||||||
|
|
||||||
|
(Or edit composer.json, then `composer update` wholesale — the repo has
|
||||||
|
few non-Symfony deps, so a full update is low-risk.)
|
||||||
|
|
||||||
|
## 4. Config / recipes to re-sync
|
||||||
|
|
||||||
|
After the bump, run `composer recipes:update` (or
|
||||||
|
`symfony console recipes:update`) and review diffs for:
|
||||||
|
|
||||||
|
- `symfony/framework-bundle` — check `config/packages/framework.yaml`
|
||||||
|
for new/changed defaults (session, cache, http_method_override, etc.).
|
||||||
|
Our `config/reference.php` dump is generated from 7.4 config; it
|
||||||
|
**must be regenerated** after upgrade
|
||||||
|
(`bin/console config:dump-reference` equivalents) or it will document
|
||||||
|
stale defaults.
|
||||||
|
- `symfony/twig-bundle`, `symfony/rate-limiter` — verify
|
||||||
|
`config/packages/*.yaml` against new reference defaults.
|
||||||
|
- `symfony/runtime` — new recipe may update `public/index.php`; accept
|
||||||
|
only if it's a no-op for our shape.
|
||||||
|
|
||||||
|
Also review `bundles.php` (only Framework + Twig today — no removals
|
||||||
|
expected in 8.x) and `config/preload.php`.
|
||||||
|
|
||||||
|
## 5. Code-level risk review
|
||||||
|
|
||||||
|
Preauth deliberately avoids the Security component (custom listeners +
|
||||||
|
`ConfigBag`), which removes the biggest 8.0 BC-break surface
|
||||||
|
(`security.yaml` reshaping, authenticator changes). Remaining surface:
|
||||||
|
|
||||||
|
- **Listeners** (`src/Listener/*`): built on HttpKernel events — stable
|
||||||
|
API, but `KernelEvents` signatures gained native types in 8.0; our
|
||||||
|
listeners already declare types, verify covariance after upgrade.
|
||||||
|
- **`Kernel.php`**: confirm no overridden methods whose signatures
|
||||||
|
changed in 8.0 (MicroKernelTrait is stable; likely no-op).
|
||||||
|
- **`symfony/console`** (GenerateBackupCodesCommand): 8.0 removed
|
||||||
|
command `setName()`/aliases-in-constructor legacy paths — we use
|
||||||
|
`#[AsCommand]`, fine. `Command::execute()` must return `int` — verify.
|
||||||
|
- **`spomky-labs/otphp`** and **`bacon/bacon-qr-code`**: third-party;
|
||||||
|
confirm versions resolved are marked Symfony-8 compatible.
|
||||||
|
- **PHPUnit 13**: no changes needed, but watch for deprecations printed
|
||||||
|
after the Symfony bump (new `trigger_deprecation` calls in 8.1).
|
||||||
|
|
||||||
|
Canonical checklist: read `symfony/symfony` **UPGRADE-8.0.md** and
|
||||||
|
**UPGRADE-8.1.md** sections for the components we require
|
||||||
|
(cache, console, framework-bundle, mime, rate-limiter, runtime,
|
||||||
|
twig-bundle, uid, yaml) and tick each item against this codebase.
|
||||||
|
|
||||||
|
## 6. Docker / CI
|
||||||
|
|
||||||
|
- `Dockerfile`: no base-image change needed
|
||||||
|
(`dunglas/frankenphp:php8.5-trixie` + `php:8.5-trixie` builder).
|
||||||
|
Rebuild after composer.lock update; remove nothing — FrankenPHP itself
|
||||||
|
stays.
|
||||||
|
- `Caddyfile`: unchanged (worker mode config is FrankenPHP-side, not
|
||||||
|
runtime-package-side).
|
||||||
|
- `.gitea/workflows/tests.yaml`: PHP 8.5 already — unchanged.
|
||||||
|
- `composer dump-env prod --empty` step stays.
|
||||||
|
|
||||||
|
## 7. Rollout plan
|
||||||
|
|
||||||
|
| Phase | Step | Exit criteria |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| 0 | **Deprecation sweep on 7.4**: run `SYMFONY_DEPRECATIONS_HELPER=max[total]=0 composer test` (or phpunit directly) + run the app in dev with the profiler/log; fix every direct deprecation. | Zero deprecations from `App\` code; only acceptable vendor ones documented. |
|
||||||
|
| 1 | **composer bump**: branch `feat/symfony-8.1`; edit composer.json per §3–§4; `composer remove runtime/frankenphp-symfony`; `composer update`; re-sync recipes. | Installs clean on PHP 8.5; `bin/console about` shows 8.1.x. |
|
||||||
|
| 2 | **Config refresh**: regenerate `config/reference.php`; review framework/twig/rate-limiter defaults; commit config changes. | `cache:clear` + warmup pass in dev & prod envs. |
|
||||||
|
| 3 | **Tests**: full phpunit suite + php-cs-fixer; fix failures (expected: minor — event/type related). | Suite green in CI. |
|
||||||
|
| 4 | **Staging smoke**: build image, run under FrankenPHP worker mode; verify TOTP login flow, backup codes, rate limiting (burst + teapot mode), public paths, central-auth subdomain flow; watch memory across >500 requests to confirm threads recycle via the Caddyfile `max_requests` setting (see §2 note). | No state leaks across worker requests; worker threads recycle at the configured request count; healthcheck passes. |
|
||||||
|
| 5 | **Docs + release**: update readme/DESIGN_CONSIDERATIONS ("symfony 8.1, built-in FrankenPHP runtime"); tag a minor release per CHANGELOG conventions. | Release published; image rebuilt & pushed. |
|
||||||
|
|
||||||
|
**Rollback:** the upgrade is a single composer.lock + config diff.
|
||||||
|
Rollback = `git revert` the bump commit + redeploy previous image tag.
|
||||||
|
No data/schema migrations are involved (no database).
|
||||||
|
|
||||||
|
## 8. Open questions — resolved during implementation
|
||||||
|
|
||||||
|
- [x] ~~Confirm none of our listeners/services relied on implicit behavior
|
||||||
|
of `Runtime\FrankenPhpSymfony\Runner`.~~ **Resolved:** audited every
|
||||||
|
class in `src/` — all are `final readonly` with constructor-injected
|
||||||
|
dependencies and no mutable state. No `ResetInterface` needed; kernel
|
||||||
|
reuse across worker requests is safe.
|
||||||
|
- [x] ~~Decide whether to pin `symfony/*` as `8.1.*` or `^8.1`.~~
|
||||||
|
**Resolved:** kept minor-pinned `8.1.*`, matching repo convention.
|
||||||
|
- [x] ~~Regenerate `config/reference.php` — scripted or manual dump?~~
|
||||||
|
**Resolved:** it's gitignored and auto-regenerated by Flex on
|
||||||
|
`composer update`; already refreshed for 8.1 during the bump.
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# v1.1 Plan — Public Rate-Limited Access
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow preauth to provide rate-limited unauthenticated access to select
|
||||||
|
public paths. Authenticated users bypass the public rate limiter entirely.
|
||||||
|
Non-public paths continue to trigger the existing auth flow.
|
||||||
|
|
||||||
|
**Practical example:** Allow anyone to visit
|
||||||
|
`https://code.devgnome.com/public/*` in Gitea, but limit them to 100
|
||||||
|
requests/minute and 500 requests/hour per IP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When a request arrives and the user is **not authenticated** (no valid
|
||||||
|
cookie or IP session), the new `PublicAccessListener` checks whether the
|
||||||
|
request path matches any configured public path pattern. If it does:
|
||||||
|
|
||||||
|
1. The public rate limiter is consulted (separate from the login limiter).
|
||||||
|
2. If within limits → `200 OK` (no `Remote-User` header). Caddy proxies
|
||||||
|
to the backend.
|
||||||
|
3. If over limits → `429 Too Many Requests` with a `Retry-After` header.
|
||||||
|
|
||||||
|
If the path does **not** match any public pattern, the request falls
|
||||||
|
through to the existing auth flow (RejectListener → LoginListener →
|
||||||
|
InterceptListener → login page or redirect).
|
||||||
|
|
||||||
|
**Authenticated users** never reach the `PublicAccessListener` because
|
||||||
|
`AcceptListener` (priority 99) or `AllowListener` (priority 88) will have
|
||||||
|
already set a `200` response before `PublicAccessListener` runs.
|
||||||
|
|
||||||
|
### Listener Priority Chain (updated)
|
||||||
|
|
||||||
|
```
|
||||||
|
Priority Listener Action
|
||||||
|
──────── ───────────────── ──────────────────────────────────────
|
||||||
|
99 AcceptListener Valid cookie → 200 OK
|
||||||
|
88 AllowListener Valid IP session → 200 OK
|
||||||
|
84 PublicAccessListener Public path + rate limit check → 200 or 429
|
||||||
|
77 RejectListener Login rate-limit gate → 418/429
|
||||||
|
66 LoginListener Login attempt handling
|
||||||
|
55 InterceptListener Fallback → redirect or login page
|
||||||
|
```
|
||||||
|
|
||||||
|
`PublicAccessListener` runs at priority 84 — after auth checks (so
|
||||||
|
authenticated users bypass it) but before `RejectListener` (so public
|
||||||
|
access is not subject to the login rate limiter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. Wildcard `*` supported. |
|
||||||
|
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP |
|
||||||
|
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds |
|
||||||
|
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP |
|
||||||
|
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour) |
|
||||||
|
|
||||||
|
**When `PUBLIC_PATHS` is empty (default), the feature is completely
|
||||||
|
disabled and has zero effect on existing behavior.**
|
||||||
|
|
||||||
|
### Path Pattern Syntax
|
||||||
|
|
||||||
|
- Patterns are matched against the request **path** only (query string
|
||||||
|
is ignored).
|
||||||
|
- Patterns must start with `/`.
|
||||||
|
- `*` matches any sequence of characters within a single path segment
|
||||||
|
(not crossing `/`).
|
||||||
|
- `**` matches any sequence of characters including `/` (crosses path
|
||||||
|
segments).
|
||||||
|
- No other regex or special characters are supported — patterns are
|
||||||
|
literal strings with `*` wildcards.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
| Pattern | Matches | Does NOT match |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| `/public` | `/public` | `/public/`, `/public/xyz` |
|
||||||
|
| `/public/*` | `/public/anything`, `/public/xyz` | `/public`, `/public/a/b` |
|
||||||
|
| `/public/**` | `/public/anything`, `/public/a/b/c` | `/public` |
|
||||||
|
| `/public` | `/public` | `/public/xyz` |
|
||||||
|
| `/api/*/status` | `/api/v1/status`, `/api/v2/status` | `/api/v1/v2/status` |
|
||||||
|
|
||||||
|
### Domain-Scoped Paths (when using auth subdomain)
|
||||||
|
|
||||||
|
When `SUBDOMAIN_REDIRECT=true` and `AUTH_SUBDOMAIN` is set, the user may
|
||||||
|
want public paths on specific subdomains only. In this case, `PUBLIC_PATHS`
|
||||||
|
can optionally include a domain prefix:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUBLIC_PATHS='code.devgnome.com/public/**,auth.devgnome.com/health'
|
||||||
|
```
|
||||||
|
|
||||||
|
When no domain prefix is given, the path matches on **any** host. When a
|
||||||
|
domain prefix is given, it only matches on that specific host.
|
||||||
|
|
||||||
|
When **not** using an auth subdomain (the common case), paths without a
|
||||||
|
domain prefix match on all hosts. Domain-prefixed entries can still be
|
||||||
|
used to restrict to specific hosts.
|
||||||
|
|
||||||
|
### Rate Limiter
|
||||||
|
|
||||||
|
A new `public_limiter` compound rate limiter is added to
|
||||||
|
`rate_limiter.yaml`, following the same pattern as the existing
|
||||||
|
`login_limiter`. It uses a `publicRateLimitCache` pool (APCu in
|
||||||
|
production, array adapter in tests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/Service/PublicPathMatcher.php` | Service that parses `PUBLIC_PATHS` and matches request paths against patterns |
|
||||||
|
| `src/Service/PublicPathMatcherInterface.php` | Interface for testability |
|
||||||
|
| `src/Listener/PublicAccessListener.php` | Listener that checks public paths and applies rate limiting |
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `config/services.yaml` | Add `PUBLIC_PATHS` and related env vars + parameters |
|
||||||
|
| `config/packages/rate_limiter.yaml` | Add `public_burst`, `public_upper`, `public_limiter` |
|
||||||
|
| `config/packages/cache.yaml` | Add `publicRateLimitCache` pool |
|
||||||
|
| `config/packages/test/cache.yaml` | Add `publicRateLimitCache` pool (array adapter) |
|
||||||
|
| `src/ConfigBag.php` | Add `publicPaths()` method returning parsed path patterns |
|
||||||
|
| `tests/TestKernel.php` | Add `publicRateLimitCache` to reset exclusion list |
|
||||||
|
| `tests/Support/ListenerTestHelper.php` | Add helper for public rate limiter factory |
|
||||||
|
| `docs/example.env` | Document new env vars |
|
||||||
|
| `.env.test` | Add test defaults for public paths vars |
|
||||||
|
| `.env` | Add dev defaults for public paths vars |
|
||||||
|
| `docs/Caddyfile` | Add example of public + protected service config |
|
||||||
|
| `CHANGELOG.md` | Add v1.1 section |
|
||||||
|
| `ROADMAP.md` | Mark Phase 1 as in-progress / completed |
|
||||||
|
| `readme.md` | Document public access feature |
|
||||||
|
|
||||||
|
## New Test Files
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `tests/Unit/Service/PublicPathMatcherTest.php` | Pattern parsing, matching, wildcards, domain scoping |
|
||||||
|
| `tests/Unit/Listener/PublicAccessListenerTest.php` | Listener logic: public path match → 200, non-public → pass through, rate limited → 429, authenticated → not reached |
|
||||||
|
| `tests/Functional/PublicAccessFlowTest.php` | End-to-end: public path accessible, rate limit enforced, non-public path shows login, authenticated user bypasses public rate limit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **`PublicPathMatcher`** — Pure path matching logic, no dependencies.
|
||||||
|
Parse the `PUBLIC_PATHS` string into pattern entries (each with
|
||||||
|
optional host + path pattern). Convert `*`/`**` wildcards to regex.
|
||||||
|
Match a given (host, path) against all patterns.
|
||||||
|
|
||||||
|
2. **Config** — Add env vars to `services.yaml`, add rate limiter to
|
||||||
|
`rate_limiter.yaml`, add cache pool to `cache.yaml` + test cache.
|
||||||
|
|
||||||
|
3. **`ConfigBag`** — Add `publicPaths()` returning the raw string (the
|
||||||
|
`PublicPathMatcher` does the parsing). Or add the `PublicPathMatcher`
|
||||||
|
as a service that receives the raw string via autowiring.
|
||||||
|
|
||||||
|
4. **`PublicAccessListener`** — Inject `PublicPathMatcherInterface`,
|
||||||
|
`RateLimiterFactoryInterface` (target `public_limiter`), and
|
||||||
|
`ConfigBag`. On `RequestEvent`:
|
||||||
|
- If no public paths configured → return immediately.
|
||||||
|
- If request already has a response → return (auth listeners ran first).
|
||||||
|
- Check if (host, path) matches any public pattern.
|
||||||
|
- If no match → return (fall through to auth flow).
|
||||||
|
- If match → consume(1) from public rate limiter.
|
||||||
|
- If over limit → set 429 response with `Retry-After`.
|
||||||
|
- If within limit → set 200 response (plain text, no `Remote-User`).
|
||||||
|
|
||||||
|
5. **Tests** — Unit tests for `PublicPathMatcher` and
|
||||||
|
`PublicAccessListener`, functional tests for the full flow.
|
||||||
|
|
||||||
|
6. **Documentation** — Update all docs.
|
||||||
|
|
||||||
|
7. **Lint + Test** — Run php-cs-fixer + phpunit, fix any issues.
|
||||||
|
|
||||||
|
8. **Commit + Push + PR.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Why priority 84?
|
||||||
|
|
||||||
|
- Must be **after** `AcceptListener` (99) and `AllowListener` (88) so
|
||||||
|
authenticated users never hit the public rate limiter.
|
||||||
|
- Must be **before** `RejectListener` (77) so public access is not
|
||||||
|
blocked by the login attempt rate limiter.
|
||||||
|
- Must be **before** `LoginListener` (66) so login attempts on public
|
||||||
|
paths are still processed (though this is an edge case — a login
|
||||||
|
attempt on a public path would set a response in `PublicAccessListener`
|
||||||
|
before `LoginListener` runs, which is correct: you don't need to login
|
||||||
|
to access a public path).
|
||||||
|
|
||||||
|
**Wait — actually this is a problem.** If someone sends an `X-Preauth`
|
||||||
|
header on a public path, `PublicAccessListener` would return 200 before
|
||||||
|
`LoginListener` can process the login. But that's actually fine — if the
|
||||||
|
path is public, they don't need to log in. If they want to authenticate,
|
||||||
|
they can visit a non-public path.
|
||||||
|
|
||||||
|
**Revised approach:** `PublicAccessListener` should only return 200 for
|
||||||
|
**GET/HEAD** requests to public paths, or all methods? For a gate like
|
||||||
|
this, all methods should be allowed on public paths — the backend
|
||||||
|
service (e.g., Gitea) handles its own authorization for write
|
||||||
|
operations.
|
||||||
|
|
||||||
|
### Why a separate rate limiter?
|
||||||
|
|
||||||
|
The existing `login_limiter` rate limits **login attempts** (failures).
|
||||||
|
The public rate limiter rate limits **all requests** to public paths.
|
||||||
|
They serve different purposes and need independent counters. Using the
|
||||||
|
same limiter would mean public traffic could exhaust the login attempt
|
||||||
|
budget, or vice versa.
|
||||||
|
|
||||||
|
### Why `Retry-After` header?
|
||||||
|
|
||||||
|
It's a standard HTTP header (RFC 7231) that tells clients how long to
|
||||||
|
wait before retrying. Legitimate clients (browsers, API consumers) and
|
||||||
|
crawlers respect it.
|
||||||
|
|
||||||
|
### Why no `Remote-User` header on public responses?
|
||||||
|
|
||||||
|
The `Remote-User` header tells the backend who the authenticated user
|
||||||
|
is. For public access, there is no authenticated user. Sending
|
||||||
|
`Remote-User: public` or similar could confuse the backend. The backend
|
||||||
|
should treat requests without `Remote-User` as anonymous.
|
||||||
|
|
||||||
|
### Path matching: query strings
|
||||||
|
|
||||||
|
Query strings are **ignored** for path matching. `/public?foo=bar`
|
||||||
|
matches the pattern `/public`. This is implemented by using
|
||||||
|
`$request->getPathInfo()` which returns the path without query string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **Empty `PUBLIC_PATHS`** → Feature disabled, zero impact on existing
|
||||||
|
behavior. All tests pass unchanged.
|
||||||
|
|
||||||
|
2. **Authenticated user visits a public path** → `AcceptListener` or
|
||||||
|
`AllowListener` returns 200 before `PublicAccessListener` runs. The
|
||||||
|
public rate limiter is never consulted.
|
||||||
|
|
||||||
|
3. **Public path rate limit exceeded** → 429 with `Retry-After` header.
|
||||||
|
The response uses the error template (same as login rate limit) but
|
||||||
|
always with 429 status (never teapot — teapot is for login failures).
|
||||||
|
|
||||||
|
4. **Non-public path on a host that has some public paths** → Falls
|
||||||
|
through to the normal auth flow. Login page or redirect.
|
||||||
|
|
||||||
|
5. **`PUBLIC_PATHS` with whitespace** → Trimmed during parsing.
|
||||||
|
`PUBLIC_PATHS='/public, /api'` is equivalent to `/public,/api`.
|
||||||
|
|
||||||
|
6. **Invalid patterns** (not starting with `/`) → Silently ignored
|
||||||
|
during parsing. Logged at debug level.
|
||||||
|
|
||||||
|
7. **Login attempt on a public path** → `PublicAccessListener` returns
|
||||||
|
200 before `LoginListener` runs. This is correct behavior — if the
|
||||||
|
path is public, no login is needed.
|
||||||
|
|
||||||
|
8. **Subdomain redirect mode + public paths** → If using an auth
|
||||||
|
subdomain, requests to the auth subdomain itself should never be
|
||||||
|
treated as public. The `PublicAccessListener` should skip requests
|
||||||
|
where `host === authSubdomain`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### Unit Tests — `PublicPathMatcherTest`
|
||||||
|
|
||||||
|
- Empty string → no patterns → matches nothing
|
||||||
|
- Single path `/public` → matches exact, not `/public/`
|
||||||
|
- Wildcard `/public/*` → matches `/public/x`, not `/public`, not `/public/a/b`
|
||||||
|
- Double wildcard `/public/**` → matches `/public/a/b/c`
|
||||||
|
- Multiple patterns comma-separated
|
||||||
|
- Domain-prefixed pattern `host.example.com/public/**`
|
||||||
|
- Path without domain prefix matches any host
|
||||||
|
- Whitespace trimming
|
||||||
|
- Invalid patterns (no leading `/`) ignored
|
||||||
|
- Case sensitivity (paths are case-sensitive, hosts are case-insensitive)
|
||||||
|
|
||||||
|
### Unit Tests — `PublicAccessListenerTest`
|
||||||
|
|
||||||
|
- No public paths configured → returns without setting response
|
||||||
|
- Non-public path → returns without setting response
|
||||||
|
- Public path, within rate limit → sets 200 response
|
||||||
|
- Public path, rate limit exceeded → sets 429 response with Retry-After
|
||||||
|
- Public path, response already set by earlier listener → returns
|
||||||
|
- Auth subdomain request → skipped (even if path matches)
|
||||||
|
- Uses `ListenerTestHelper` for mock rate limiters and collaborators
|
||||||
|
|
||||||
|
### Functional Tests — `PublicAccessFlowTest`
|
||||||
|
|
||||||
|
- Public path accessible without authentication → 200
|
||||||
|
- Non-public path without auth → 401 (login page)
|
||||||
|
- Rate limit enforcement: multiple requests exceed burst → 429
|
||||||
|
- Authenticated user visits public path → 200 with Remote-User (bypasses public limiter)
|
||||||
|
- 429 response includes Retry-After header
|
||||||
|
- Query string ignored for path matching
|
||||||
|
- Wildcard matching works end-to-end
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
### README
|
||||||
|
|
||||||
|
New section: **"Public Rate-Limited Access"** under Configuration.
|
||||||
|
|
||||||
|
- Explain the feature and use case
|
||||||
|
- Document all env vars
|
||||||
|
- Show path pattern syntax with examples
|
||||||
|
- Show Caddyfile configuration for public + protected services
|
||||||
|
- Note that authenticated users bypass the public rate limiter
|
||||||
|
|
||||||
|
### CHANGELOG
|
||||||
|
|
||||||
|
New `[Unreleased]` → v1.1 section with all new features.
|
||||||
|
|
||||||
|
### ROADMAP
|
||||||
|
|
||||||
|
Mark Phase 1 items as completed.
|
||||||
|
|
||||||
|
### docs/example.env
|
||||||
|
|
||||||
|
Add all new env vars with comments.
|
||||||
|
|
||||||
|
### docs/Caddyfile
|
||||||
|
|
||||||
|
Add example showing a service with both public and protected paths.
|
||||||
@@ -0,0 +1,985 @@
|
|||||||
|
parameters:
|
||||||
|
ignoreErrors:
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Clock\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Clock.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:configure\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:configure\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Command/GenerateBackupCodesCommand.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:execute\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:execute\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Command/GenerateBackupCodesCommand.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$id\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$json\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$nonce\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$scope\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$token\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Data\\Payload\:\:load\(\) has parameter \$input with generic class Symfony\\Component\\HttpFoundation\\InputBag but does not specify its types\: TInput$#'
|
||||||
|
identifier: missingType.generics
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Kernel has an uninitialized property \$persistCache\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Kernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Kernel\:\:boot\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:boot\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Kernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Kernel\:\:terminate\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:terminate\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Kernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\AcceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AcceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\AcceptListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AcceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\AllowListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AllowListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\AllowListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AllowListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Property App\\Listener\\InterceptListener\:\:\$config is never read, only written\.$#'
|
||||||
|
identifier: property.onlyWritten
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 2
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 2
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||||
|
identifier: nullsafe.neverNull
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Using nullsafe property access "\?\-\>json" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||||
|
identifier: nullsafe.neverNull
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\PublicAccessListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/PublicAccessListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\PublicAccessListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/PublicAccessListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Using nullsafe method call on non\-nullable type DateTimeImmutable\. Use \-\> instead\.$#'
|
||||||
|
identifier: nullsafe.neverNull
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/PublicAccessListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\RejectListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/RejectListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\RejectListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/RejectListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:allValid\(\) has parameter \$keys with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getChanges\(\) return type has no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getKeys\(\) return type has no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:expire\(\) overrides method App\\Service\\BackupCodeInterface\:\:expire\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:generate\(\) overrides method App\\Service\\BackupCodeInterface\:\:generate\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:saveCodes\(\) has parameter \$codes with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:verifyAndConsume\(\) overrides method App\\Service\\BackupCodeInterface\:\:verifyAndConsume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$config is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:authBase\(\) overrides method App\\Service\\DomainInterface\:\:authBase\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:getAuthSubdomain\(\) overrides method App\\Service\\DomainInterface\:\:getAuthSubdomain\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:matchesAuth\(\) overrides method App\\Service\\DomainInterface\:\:matchesAuth\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:validReturn\(\) overrides method App\\Service\\DomainInterface\:\:validReturn\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\LoginManager\:\:checkToken\(\) overrides method App\\Service\\LoginInterface\:\:checkToken\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\LoginManager\:\:\$config is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\LoginManager\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\LoginManager\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\PublicPathMatcher\:\:isEmpty\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:isEmpty\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/PublicPathMatcher.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\PublicPathMatcher\:\:matches\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:matches\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/PublicPathMatcher.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:loginPayload\(\) is unused\.$#'
|
||||||
|
identifier: method.unused
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\TestKernel\:\:build\(\) overrides method Symfony\\Component\\DependencyInjection\\Kernel\\AbstractKernel\:\:build\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/TestKernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface@anonymous/tests/TestKernel\.php\:31\:\:process\(\) overrides method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface\:\:process\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/TestKernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Unit\\Command\\GenerateBackupCodesCommandTest\:\:makeManagerStub\(\) has parameter \$generatedCodes with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Command/GenerateBackupCodesCommandTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/ConfigBagRemoteUserTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#'
|
||||||
|
identifier: staticMethod.alreadyNarrowedType
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Enum/ScopeTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/AcceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/AllowListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Unit\\Listener\\LoginListenerTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#'
|
||||||
|
identifier: staticMethod.alreadyNarrowedType
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
|
||||||
|
identifier: staticMethod.alreadyNarrowedType
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
|
||||||
|
identifier: method.notFound
|
||||||
|
count: 17
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$backupCodeManager\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$domainManager\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$pool\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22 has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22\:\:\$config is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Trait\\HasLoggerTraitTest has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/HasLoggerTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Tests\\Unit\\Trait\\HasLoggerTraitTest\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/HasLoggerTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAfter\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAfter\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAt\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAt\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:get\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:get\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:getKey\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:getKey\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:isHit\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:isHit\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:set\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:set\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) return type has no value type specified in iterable type iterable\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/StringTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#'
|
||||||
|
identifier: function.impossibleType
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to method bootEnv\(\) on an unknown class Symfony\\Component\\Dotenv\\Dotenv\.$#'
|
||||||
|
identifier: class.notFound
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
|
||||||
|
identifier: class.notFound
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Instantiated class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
|
||||||
|
identifier: class.notFound
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# phpstan.neon.dist — canonical shared PHPStan config.
|
||||||
|
#
|
||||||
|
# Copy verbatim into a project root. This is a LEAF file: it has no
|
||||||
|
# project-specific content, so "sync it" means "overwrite it", never merge.
|
||||||
|
# Do not hand-edit per repo — change it here and re-sync, or the five copies
|
||||||
|
# drift back apart (GUIDING-LIGHT §8.2).
|
||||||
|
#
|
||||||
|
# Baseline: level 6 for application code. Raise per project as it gets clean;
|
||||||
|
# the goal recorded in GUIDING-LIGHT §2.2 is level 6 minimum everywhere.
|
||||||
|
#
|
||||||
|
# Adopt incrementally:
|
||||||
|
# 1. vendor/bin/phpstan analyse --generate-baseline
|
||||||
|
# 2. Commit the result over the empty phpstan-baseline.neon that ships with this
|
||||||
|
# 3. Ratchet `level` up as the baseline shrinks
|
||||||
|
# Never replace a fix with an ignoreErrors entry — see reportIgnoresWithoutComments.
|
||||||
|
#
|
||||||
|
# IMPORTANT — every key below is VERIFIED against phpstan.org/config-reference.
|
||||||
|
# PHPStan 2.x errors on unknown keys, but a plausible-looking wrong key copied
|
||||||
|
# from a blog post is a common way to lose an afternoon. If you add a key,
|
||||||
|
# confirm it there first. Extension-specific keys (symfony.*, doctrine.*,
|
||||||
|
# phpstan-deprecation-rules, etc.) are deliberately NOT set here — see the
|
||||||
|
# commented block at the bottom for why and how to opt in per project.
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
level: 6
|
||||||
|
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
- tests
|
||||||
|
|
||||||
|
# ── High-signal checks (all verified key names) ──────────────────────────
|
||||||
|
|
||||||
|
# An `@var` that contradicts the assignment is almost always a real bug.
|
||||||
|
reportWrongPhpDocTypeInVarTag: true
|
||||||
|
|
||||||
|
# A `@var` that only widens the inferred type is usually an unnecessary cast.
|
||||||
|
reportAnyTypeWideningInVarTag: true
|
||||||
|
|
||||||
|
# `@param`/`@return` that contradict the native signature.
|
||||||
|
reportStaticMethodSignatures: true
|
||||||
|
|
||||||
|
# An ignoreErrors entry with no explanatory comment is a smell.
|
||||||
|
reportIgnoresWithoutComments: true
|
||||||
|
|
||||||
|
# Forces every ignore to still match something. Without this, ignores
|
||||||
|
# accumulate forever and nobody notices when the underlying bug is fixed.
|
||||||
|
reportUnmatchedIgnoredErrors: true
|
||||||
|
|
||||||
|
# Catch `Foo` vs `foo` in function names — matters for Windows devs and
|
||||||
|
# for correctness under strict autoloading.
|
||||||
|
checkFunctionNameCase: true
|
||||||
|
|
||||||
|
# Typed properties that are read before they are definitely initialised.
|
||||||
|
checkUninitializedProperties: true
|
||||||
|
|
||||||
|
# Respect #[Override] so refactors in parent classes cannot silently stop
|
||||||
|
# overriding a method that got renamed.
|
||||||
|
checkMissingOverrideMethodAttribute: true
|
||||||
|
checkMissingOverridePropertyAttribute: true
|
||||||
|
|
||||||
|
# Static analysis cannot see through sprintf, so mis-ordered placeholders
|
||||||
|
# are otherwise invisible until runtime.
|
||||||
|
checkStrictPrintfPlaceholderTypes: true
|
||||||
|
|
||||||
|
# Dynamic properties are deprecated in PHP 8.2+ and are a common source of
|
||||||
|
# typos that would otherwise fail silently at runtime.
|
||||||
|
checkDynamicProperties: true
|
||||||
|
|
||||||
|
# All five repos have this file (verified); the kernel boot lives here.
|
||||||
|
bootstrapFiles:
|
||||||
|
- tests/bootstrap.php
|
||||||
|
|
||||||
|
ignoreErrors:
|
||||||
|
# Symfony's createClient() returns KernelBrowser, but some test helpers
|
||||||
|
# are typed against the narrower legacy interface.
|
||||||
|
# reportUnmatched:false so this does not fail the build once the
|
||||||
|
# offending helper is typed properly.
|
||||||
|
#
|
||||||
|
# NOTE the layout: the dash sits alone and the keys are indented under
|
||||||
|
# it. This is the form used verbatim in PHPStan's own documentation.
|
||||||
|
# (The more compact `- message: ...` / continuation form is also valid
|
||||||
|
# NEON, but NOT every NEON parser in the wild handles it — the PHP
|
||||||
|
# parser PHPStan uses handles both, Python's neon-py handles neither
|
||||||
|
# reliably. Staying with the documented form avoids the argument.)
|
||||||
|
-
|
||||||
|
message: '#Call to an undefined method Symfony\\Component\\HttpFoundation\\Session\\SessionInterface::#'
|
||||||
|
reportUnmatched: false
|
||||||
|
|
||||||
|
includes:
|
||||||
|
# Ships EMPTY with this config. A repo overwrites it when it runs
|
||||||
|
# --generate-baseline. It must exist: a missing `includes` target is a hard
|
||||||
|
# error, not a silent skip, which is why the empty file is committed rather
|
||||||
|
# than the include being made conditional.
|
||||||
|
- phpstan-baseline.neon
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# OPTIONAL EXTENSIONS — commented out on purpose.
|
||||||
|
#
|
||||||
|
# These keys are owned by PHPStan *extensions*, not core. If the extension is
|
||||||
|
# not installed, or the key name drifts between extension majors, analysis
|
||||||
|
# fails outright. So they are opt-in per project rather than shared.
|
||||||
|
#
|
||||||
|
# Symfony — resolves service ids, autowiring, and container params from the
|
||||||
|
# compiled container. Requires phpstan/phpstan-symfony. Uncomment AND make sure
|
||||||
|
# the path exists (warm the dev cache first, or let the test bootstrap do it).
|
||||||
|
#
|
||||||
|
# symfony:
|
||||||
|
# containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
|
||||||
|
#
|
||||||
|
# Doctrine — validates DQL against the actual mapping. Requires
|
||||||
|
# phpstan/phpstan-doctrine. `repositoryClass` must name a class that EXISTS in
|
||||||
|
# the project; setting it to a class you do not have is an instant failure.
|
||||||
|
#
|
||||||
|
# doctrine:
|
||||||
|
# repositoryClass: App\Repository\YourBaseRepository
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
colors="true"
|
||||||
|
failOnDeprecation="true"
|
||||||
|
failOnNotice="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1" />
|
||||||
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
|
<server name="KERNEL_CLASS" value="App\Tests\TestKernel" />
|
||||||
|
<!-- fixed TOTP secret so functional tests can compute valid codes -->
|
||||||
|
<server name="TOTP_URI" value="otpauth://totp/Test-TOTP?secret=JBSWY3DPEHPK3PXP" />
|
||||||
|
<server name="APP_SECRET" value="test_secret_key_change_me" />
|
||||||
|
<!-- high rate limits so functional tests don't get blocked -->
|
||||||
|
<server name="BURST_COUNT" value="10000" />
|
||||||
|
<server name="UPPER_COUNT" value="10000" />
|
||||||
|
<!-- public access: enable for functional tests with low limits -->
|
||||||
|
<server name="PUBLIC_PATHS" value="/public/**" />
|
||||||
|
<server name="PUBLIC_BURST_COUNT" value="3" />
|
||||||
|
<server name="PUBLIC_BURST_TIME" value="60" />
|
||||||
|
<server name="PUBLIC_UPPER_COUNT" value="10000" />
|
||||||
|
<server name="PUBLIC_UPPER_TIME" value="3600" />
|
||||||
|
</php>
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Project Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
|
ignoreIndirectDeprecations="true"
|
||||||
|
restrictNotices="true"
|
||||||
|
restrictWarnings="true"
|
||||||
|
>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
|
||||||
|
<deprecationTrigger>
|
||||||
|
<function>trigger_deprecation</function>
|
||||||
|
</deprecationTrigger>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
</extensions>
|
||||||
|
</phpunit>
|
||||||
+2
-1
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Kernel;
|
use App\Kernel;
|
||||||
|
|
||||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
return function (array $context) {
|
return static function (array $context) {
|
||||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,68 +1,309 @@
|
|||||||
# Preauth
|
# Preauth
|
||||||
For when you want to expose a web service without letting the whole world try to access it. Because sometimes you want both a belt and suspenders.
|
|
||||||
|
|
||||||
I found myself needing to make my personal Nextcloud instance available outside my VPN, but was worried since it has had authentication exploits in the past.
|
A lightweight TOTP authentication gateway for self-hosted web services.
|
||||||
|
|
||||||
So, I built a simple authentication gateway, which eventually turned into this project.
|
Preauth sits between your reverse proxy (Caddy) and your web service,
|
||||||
|
requiring a TOTP code before traffic ever reaches the protected application.
|
||||||
|
It is **not** a replacement for your service's own authentication — it's a
|
||||||
|
gate that prevents outsiders from even seeing what service is running.
|
||||||
|
|
||||||
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
|
For when you want a belt and suspenders.
|
||||||
|
|
||||||
## Requirements
|
## Features
|
||||||
|
|
||||||
* Docker
|
- **TOTP authentication** — Time-based one-time passwords (compatible with
|
||||||
* Caddy (as a reverse proxy)
|
Google Authenticator, Authy, 1Password, etc.)
|
||||||
* a web service you want to secure
|
- **Backup codes** — Single-use backup codes for when TOTP devices are lost
|
||||||
|
- **Caddy native** — Designed for Caddy's `forward_auth` directive
|
||||||
|
- **Docker-first** — Single container, persistent volumes, no database
|
||||||
|
- **Rate limiting** — Per-IP burst and sustained limits (cannot be disabled)
|
||||||
|
- **Public rate-limited access** — Optional, allow unauthenticated access
|
||||||
|
to specific paths with separate rate limiting (e.g., public Gitea repos)
|
||||||
|
- **Central auth** — Optional subdomain-based SSO across multiple services
|
||||||
|
- **IP-based bypass** — Optional, for services that don't handle cookies
|
||||||
|
- **Customizable** — Colors, labels, messages, and error text via env vars
|
||||||
|
- **Teapot mode** — Respond with `418 I'm a Teapot` when rate-limited
|
||||||
|
(because it's more fun than `429 Too Many Requests`)
|
||||||
|
- **Cookie security** — `__Host-` prefixed cookies with `SameSite=Strict`,
|
||||||
|
`Secure`, and `HttpOnly`
|
||||||
|
- **Nonce system** — Single-use nonces prevent replay and CSRF attacks
|
||||||
|
- **Dual-layer cache** — APCu for speed, file-based persistence for restarts
|
||||||
|
|
||||||
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
|
## Quick Start
|
||||||
|
|
||||||
There is an example Caddyfile in /docs/ and env.example file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
|
### 1. Pull the Docker image
|
||||||
|
|
||||||
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.
|
```bash
|
||||||
|
docker pull digitaladapt/preauth:latest
|
||||||
|
```
|
||||||
|
|
||||||
I say login, but it's really just a TOTP code (6-digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
|
### 2. Create your environment file
|
||||||
|
|
||||||
First time you spin up the docker container it will generate a TOTP secret (which you'll load into your authenticator app); or generate you own.
|
```bash
|
||||||
|
# Generate a TOTP secret to get started
|
||||||
|
openssl rand -base64 30
|
||||||
|
```
|
||||||
|
|
||||||
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
|
Create a `.env` file (see `docs/examples/.env.example` for all options):
|
||||||
|
|
||||||
## Backup Codes
|
```env
|
||||||
|
APP_SECRET=your-random-secret-here
|
||||||
|
TOTP_URI=otpauth://totp/Preauth?secret=YOUR_SECRET
|
||||||
|
COOKIE_TTL=2592000
|
||||||
|
```
|
||||||
|
|
||||||
It is possible to generate single-use backup codes via a console command within the docker container.
|
> If `TOTP_URI` is left blank, the app will generate one on first run
|
||||||
|
> and print it to the container logs. Copy it to your `.env` file.
|
||||||
|
|
||||||
```shell
|
### 3. Start the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/examples/compose.yaml` for an example Docker Compose file.
|
||||||
|
|
||||||
|
### 4. Configure Caddy
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
service.example.com {
|
||||||
|
forward_auth preauth {
|
||||||
|
uri {uri}
|
||||||
|
copy_headers Remote-User
|
||||||
|
|
||||||
|
# keep the login flow out of browser/proxy caches
|
||||||
|
header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"
|
||||||
|
header_down Pragma "no-cache"
|
||||||
|
header_down Expires "0"
|
||||||
|
header_down Surrogate-Control "no-store"
|
||||||
|
header_down Vary "*"
|
||||||
|
}
|
||||||
|
reverse_proxy your-service:80
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/examples/Caddyfile` for more examples, including path-specific protection
|
||||||
|
and central auth subdomain configuration. The `header_down` lines above are
|
||||||
|
optional — preauth already sends these headers itself — but they guarantee
|
||||||
|
at the edge that no part of the login flow is ever cached. (2xx auth
|
||||||
|
responses are consumed by `forward_auth` and never reach the browser, so
|
||||||
|
your service's own cache headers are unaffected.)
|
||||||
|
|
||||||
|
### 5. Generate backup codes (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
|
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
|
||||||
```
|
```
|
||||||
|
|
||||||
### History
|
## Requirements
|
||||||
#### v0.7.0 (May 29th, 2026)
|
|
||||||
Added ability to generate single-use backup codes.
|
|
||||||
Removed static password and lookup token, as they were security risks.
|
|
||||||
Updated to PHP 8.5, updated dependencies.
|
|
||||||
|
|
||||||
#### v0.6.0 (Feb 10th, 2026)
|
- **Docker** — Preauth runs as a Docker container
|
||||||
Added optional (disabled by default) ability to lookup token by static password.
|
- **Caddy** — As your reverse proxy (uses `forward_auth` directive)
|
||||||
|
- **A web service** — The application you want to protect
|
||||||
|
|
||||||
#### v0.5.0 (Jan 17th, 2026)
|
Other reverse proxies with similar `forward_auth` / `auth_request`
|
||||||
Nonce related cleanup; added optional (disabled by default) ability to use a static password as a backup means of authentication.
|
capabilities may work, but only Caddy is officially supported.
|
||||||
|
|
||||||
#### v0.4.1 (Dec 26th, 2025)
|
## Configuration
|
||||||
Fixed bug which can occur if you delete cache files.
|
|
||||||
|
|
||||||
#### v0.4.0 (Dec 26th, 2025)
|
All configuration is via environment variables. See `docs/examples/.env.example`
|
||||||
Massive rewrite to switch to using listeners instead of controller, header for login payload instead of get request, removed icon system, asset system, was able to remove all the domain processing, enhanced cookie security, and more.
|
for the complete reference.
|
||||||
|
|
||||||
#### v0.3.0 (Dec 15th, 2025)
|
### Main Options
|
||||||
Includes significant breaking changes.
|
|
||||||
Default port and transportation changed to http via port 80.
|
|
||||||
Names of environment variables have changed.
|
|
||||||
|
|
||||||
#### v0.2.0 (Dec 3rd, 2025)
|
| Variable | Default | Description |
|
||||||
Now with login rate limiting.
|
|----------|---------|-------------|
|
||||||
New page for client error (too many requests).
|
| `TOTP_URI` | _(empty)_ | TOTP provisioning URI. If blank, one is generated on first run. |
|
||||||
Made example docker compose.
|
| `COOKIE_TTL` | `2592000` | Session duration in seconds (default: 30 days). |
|
||||||
|
| `SUBDOMAIN_REDIRECT` | `0` | Enable central auth across subdomains (boolean). |
|
||||||
|
| `AUTH_SUBDOMAIN` | _(empty)_ | Hostname for central auth (e.g., `auth.example.com`). |
|
||||||
|
|
||||||
#### v0.1.0 (Nov 14th, 2025)
|
### Extra Options
|
||||||
Now an actual project, docker image pushed to docker hub, which uses php-fpm, code into a src folder, templates into separate files.
|
|
||||||
|
|
||||||
#### v0.0.1 (June 26th, 2024)
|
| Variable | Default | Description |
|
||||||
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.
|
|----------|---------|-------------|
|
||||||
|
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). |
|
||||||
|
| `TEAPOT` | `1` | Respond with 418 instead of 429 when rate-limited (boolean). |
|
||||||
|
| `MAX_REQUESTS` | `500` | Restart each FrankenPHP worker thread after this many requests to contain memory growth (`0` = unlimited). Maps to the Caddyfile `max_requests` directive. |
|
||||||
|
|
||||||
|
### Remote-User Header
|
||||||
|
|
||||||
|
The `Remote-User` header sent to backends on successful auth is configurable:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `REMOTE_USER` | `session` | Mode: `session`, `static`, `mapped`, or `none`. |
|
||||||
|
| `REMOTE_USER_STATIC` | `authenticated` | Value sent when mode is `static`. |
|
||||||
|
| `REMOTE_USER_MAP` | _(empty)_ | Comma-separated map for `mapped` mode (e.g. `alice:admin,bob:user`). |
|
||||||
|
|
||||||
|
- **`session`** (default): Sends the session id. Backward-compatible.
|
||||||
|
- **`static`**: Sends a fixed string for all authenticated requests.
|
||||||
|
- **`mapped`**: Looks up the session id in the map; falls back to session id if not found.
|
||||||
|
- **`none`**: Omits the header entirely (Caddy still accepts based on status code).
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BURST_COUNT` | `2` | Max attempts per burst window. |
|
||||||
|
| `BURST_TIME` | `30` | Burst window in seconds. |
|
||||||
|
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
||||||
|
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
||||||
|
|
||||||
|
### Public Rate-Limited Access
|
||||||
|
|
||||||
|
Preauth can provide rate-limited unauthenticated access to select public
|
||||||
|
paths. This is useful for exposing public content (e.g., public repositories
|
||||||
|
in Gitea) without requiring TOTP authentication, while protecting server
|
||||||
|
resources from bot traffic.
|
||||||
|
|
||||||
|
When `PUBLIC_PATHS` is configured, requests to matching paths from
|
||||||
|
unauthenticated users are allowed through with a separate rate limiter.
|
||||||
|
Authenticated users bypass the public rate limiter entirely.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. See below. |
|
||||||
|
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP. |
|
||||||
|
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds. |
|
||||||
|
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP. |
|
||||||
|
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour). |
|
||||||
|
|
||||||
|
**Path pattern syntax:**
|
||||||
|
|
||||||
|
- Patterns are matched against the request path only (query string ignored).
|
||||||
|
- Patterns must start with `/`.
|
||||||
|
- `*` matches one or more characters within a single path segment (not crossing `/`).
|
||||||
|
- `**` matches zero or more characters including `/` (crosses path segments).
|
||||||
|
- An optional host prefix can restrict a pattern to a specific host
|
||||||
|
(e.g., `code.example.com/public/**`).
|
||||||
|
|
||||||
|
| Pattern | Matches | Does NOT match |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| `/public` | `/public` | `/public/`, `/public/repo` |
|
||||||
|
| `/public/*` | `/public/repo` | `/public`, `/public/a/b` |
|
||||||
|
| `/public/**` | `/public/repo`, `/public/a/b/c` | `/public` |
|
||||||
|
| `host.com/api/**` | `host.com/api/v1/status` | `other.com/api/v1/status` |
|
||||||
|
|
||||||
|
**Example:** Allow public access to Gitea's `/public/` paths:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PUBLIC_PATHS=/public/**
|
||||||
|
PUBLIC_BURST_COUNT=100
|
||||||
|
PUBLIC_BURST_TIME=60
|
||||||
|
PUBLIC_UPPER_COUNT=500
|
||||||
|
PUBLIC_UPPER_TIME=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
When a visitor exceeds the rate limit, they receive a `429 Too Many Requests`
|
||||||
|
response with a `Retry-After` header. When within limits, they receive a
|
||||||
|
`200 OK` response (with no `Remote-User` header). Authenticated users receive
|
||||||
|
`200 OK` with their `Remote-User` header as normal.
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
All UI text and colors are configurable:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `TITLE` | `Pre-Authentication System` | Page title. |
|
||||||
|
| `BG_COLOR` | `#029386` | Background color. |
|
||||||
|
| `FG_COLOR` | `#ffffff` | Foreground (text) color. |
|
||||||
|
| `ERROR_COLOR` | `#ffb16d` | Error message color. |
|
||||||
|
| `ID_NAME` | `Session ID` | Label for the ID field. |
|
||||||
|
| `TOKEN_NAME` | `Authentication Token` | Label for the TOTP field. |
|
||||||
|
| `SUBMIT_NAME` | `Submit` | Submit button text. |
|
||||||
|
| `ERROR_MESSAGE` | `Unsuccessful login attempt` | Failed login message. |
|
||||||
|
| `TEAPOT_TITLE` | `I'm a teapot` | Title when rate-limited (teapot mode). |
|
||||||
|
| `TEAPOT_MESSAGE` | `I refuse to brew coffee` | Message when rate-limited (teapot mode). |
|
||||||
|
| `TOO_MANY_TITLE` | `Too many requests` | Title when rate-limited (non-teapot). |
|
||||||
|
| `TOO_MANY_MESSAGE` | `Try again later` | Message when rate-limited (non-teapot). |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → Caddy → forward_auth → Preauth listeners → 200/401/418
|
||||||
|
```
|
||||||
|
|
||||||
|
Preauth is entirely event-listener-driven (no controllers). Each request
|
||||||
|
passes through a priority-ordered chain of listeners:
|
||||||
|
|
||||||
|
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
|
||||||
|
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
|
||||||
|
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
||||||
|
allows rate-limited unauthenticated access to matching paths.
|
||||||
|
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
||||||
|
5. **LoginListener** (priority 66) — Processes login attempts.
|
||||||
|
6. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||||
|
7. **SecurityHeadersListener** (response) — Adds security headers.
|
||||||
|
|
||||||
|
### Security Model
|
||||||
|
|
||||||
|
- **Cookies**: `__Host-` prefixed, `SameSite=Strict`, `Secure`, `HttpOnly`
|
||||||
|
- **Nonces**: 15-byte random, single-use, 120-second TTL
|
||||||
|
- **TOTP**: ±1 period leeway (±30 seconds) for clock drift
|
||||||
|
- **Backup codes**: Case-insensitive, single-use, alphanumeric
|
||||||
|
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
|
||||||
|
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
|
||||||
|
Referrer-Policy, HSTS
|
||||||
|
- **No cacheable login flow**: The login page, failed logins, redirects,
|
||||||
|
and rate-limit pages are sent with strict anti-caching headers
|
||||||
|
(`no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0,
|
||||||
|
s-maxage=0` plus `Pragma`, `Expires`, `Surrogate-Control`, and
|
||||||
|
`Vary: *`), and the login form's `fetch()` opts out of the HTTP cache.
|
||||||
|
Successful (2xx) responses are deliberately excluded — they are
|
||||||
|
consumed by the proxy's `forward_auth` check and never reach the
|
||||||
|
browser, so a protected service's own caching is not affected.
|
||||||
|
|
||||||
|
### Cache
|
||||||
|
|
||||||
|
Preauth uses a dual-layer cache:
|
||||||
|
- **APCu** (in-memory) — Fast session and nonce lookups
|
||||||
|
- **Filesystem** — Persistent storage for container restarts
|
||||||
|
|
||||||
|
`MonitorCacheKeys` wraps the PSR-6 cache pool to track changes, so only
|
||||||
|
modified items are persisted to disk on shutdown.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and
|
||||||
|
includes `php-cs-fixer` as a dev dependency.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for style violations
|
||||||
|
vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||||
|
|
||||||
|
# Auto-fix
|
||||||
|
vendor/bin/php-cs-fixer fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite includes 293 tests with 100% code coverage (lines, methods,
|
||||||
|
and classes). Both unit tests and functional tests (full HTTP kernel flow)
|
||||||
|
are included.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- PHP 8.4+
|
||||||
|
- Composer
|
||||||
|
- Xdebug (for coverage reports)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see `license.txt`.
|
||||||
|
|
||||||
|
## Project Status
|
||||||
|
|
||||||
|
Running in production since June 2024, protecting multiple self-hosted
|
||||||
|
services. The core authentication gate is complete and battle-tested.
|
||||||
|
|
||||||
|
See `ROADMAP.md` for planned features and `CHANGELOG.md` for version history.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared application constants.
|
||||||
|
*/
|
||||||
|
final class AppConstants
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Far-future expiration date used for persistent cache items
|
||||||
|
* (TOTP secrets, backup codes) that should effectively never expire.
|
||||||
|
* Per PSR-6, if no expiration is set, the implementation may set a
|
||||||
|
* default — we use this to be explicit.
|
||||||
|
*/
|
||||||
|
public const string FAR_FUTURE_DATE = '2999-12-31';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum length for user-supplied input fields (id, nonce, token).
|
||||||
|
* Also used for cache key truncation.
|
||||||
|
*/
|
||||||
|
public const int MAX_INPUT_LENGTH = 128;
|
||||||
|
}
|
||||||
+5
-2
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
@@ -8,8 +9,10 @@ use Psr\Clock\ClockInterface;
|
|||||||
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
|
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
|
||||||
|
|
||||||
#[AsAlias(ClockInterface::class)]
|
#[AsAlias(ClockInterface::class)]
|
||||||
final readonly class Clock implements ClockInterface {
|
final readonly class Clock implements ClockInterface
|
||||||
public function now(): DateTimeImmutable {
|
{
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
return new DateTimeImmutable();
|
return new DateTimeImmutable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\PersistCache;
|
use App\PersistCache;
|
||||||
use App\Service\BackupCodeManager;
|
use App\Service\BackupCodeInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
/** simple console command to generate backup codes
|
/** simple console command to generate backup codes
|
||||||
* usage: php bin/console app:generate-backup-codes [count] */
|
* usage: php bin/console app:generate-backup-codes [count] */
|
||||||
final class GenerateBackupCodesCommand extends Command {
|
#[AsCommand(name: 'app:generate-backup-codes')]
|
||||||
|
final class GenerateBackupCodesCommand extends Command
|
||||||
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BackupCodeManager $manager,
|
private readonly BackupCodeInterface $manager,
|
||||||
private readonly PersistCache $persistCache,
|
private readonly PersistCache $persistCache,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void {
|
protected function configure(): void
|
||||||
$this->setName('app:generate-backup-codes');
|
{
|
||||||
$this->setDescription('Generate single‑use backup codes')
|
$this->setDescription('Generate single-use backup codes')
|
||||||
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
|
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
|
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
|
||||||
$this->persistCache->boot();
|
$this->persistCache->boot();
|
||||||
$count = (int) $input->getArgument('count');
|
$count = (int) $input->getArgument('count');
|
||||||
|
if ($count < 1) {
|
||||||
|
throw new ConsoleInvalidArgumentException('Count must be a positive integer.');
|
||||||
|
}
|
||||||
$codes = $this->manager->generate($count);
|
$codes = $this->manager->generate($count);
|
||||||
foreach ($codes as $code) {
|
foreach ($codes as $code) {
|
||||||
$output->writeln($code);
|
$output->writeln($code);
|
||||||
}
|
}
|
||||||
$this->persistCache->persist();
|
$this->persistCache->persist();
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-9
@@ -1,13 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
|
|
||||||
|
use App\Enum\RemoteUserMode;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Psr\Clock\ClockInterface;
|
use Psr\Clock\ClockInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
final readonly class ConfigBag {
|
final readonly class ConfigBag
|
||||||
|
{
|
||||||
private ClockInterface $clock;
|
private ClockInterface $clock;
|
||||||
private int $cookieTtl;
|
private int $cookieTtl;
|
||||||
private string $totpUri;
|
private string $totpUri;
|
||||||
@@ -16,6 +19,10 @@ final readonly class ConfigBag {
|
|||||||
private string $errorMessage;
|
private string $errorMessage;
|
||||||
private string $teapotTitle;
|
private string $teapotTitle;
|
||||||
private string $tooManyTitle;
|
private string $tooManyTitle;
|
||||||
|
private RemoteUserMode $remoteUserMode;
|
||||||
|
private string $remoteUserStatic;
|
||||||
|
/** @var array<string,string> */
|
||||||
|
private array $remoteUserMap;
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -28,6 +35,9 @@ final readonly class ConfigBag {
|
|||||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||||
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
||||||
|
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
||||||
|
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
|
||||||
|
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
|
||||||
) {
|
) {
|
||||||
$this->clock = $clock;
|
$this->clock = $clock;
|
||||||
$this->cookieTtl = $cookieTtl;
|
$this->cookieTtl = $cookieTtl;
|
||||||
@@ -37,37 +47,89 @@ final readonly class ConfigBag {
|
|||||||
$this->errorMessage = $errorMessage;
|
$this->errorMessage = $errorMessage;
|
||||||
$this->teapotTitle = $teapotTitle;
|
$this->teapotTitle = $teapotTitle;
|
||||||
$this->tooManyTitle = $tooManyTitle;
|
$this->tooManyTitle = $tooManyTitle;
|
||||||
|
|
||||||
|
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
||||||
|
$this->remoteUserStatic = $remoteUserStatic;
|
||||||
|
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clock(): ClockInterface {
|
/**
|
||||||
|
* Parse a comma-separated map string ("id1:user1,id2:user2") into an array.
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function parseUserMap(string $map): array
|
||||||
|
{
|
||||||
|
if ('' === $map) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach (explode(',', $map) as $pair) {
|
||||||
|
$parts = explode(':', trim($pair), 2);
|
||||||
|
if (2 === \count($parts)) {
|
||||||
|
$result[trim($parts[0])] = trim($parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clock(): ClockInterface
|
||||||
|
{
|
||||||
return $this->clock;
|
return $this->clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cookieTtl(): int {
|
public function cookieTtl(): int
|
||||||
|
{
|
||||||
return $this->cookieTtl;
|
return $this->cookieTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function totpUri(): string {
|
public function totpUri(): string
|
||||||
|
{
|
||||||
return $this->totpUri;
|
return $this->totpUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ipTtl(): ?int {
|
public function ipTtl(): ?int
|
||||||
|
{
|
||||||
return $this->ipTtl;
|
return $this->ipTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function teapot(): bool {
|
public function teapot(): bool
|
||||||
|
{
|
||||||
return $this->teapot;
|
return $this->teapot;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function errorMessage(): string {
|
public function errorMessage(): string
|
||||||
|
{
|
||||||
return $this->errorMessage;
|
return $this->errorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function teapotTitle(): string {
|
public function teapotTitle(): string
|
||||||
|
{
|
||||||
return $this->teapotTitle;
|
return $this->teapotTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tooManyTitle(): string {
|
public function tooManyTitle(): string
|
||||||
|
{
|
||||||
return $this->tooManyTitle;
|
return $this->tooManyTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function remoteUserMode(): RemoteUserMode
|
||||||
|
{
|
||||||
|
return $this->remoteUserMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remoteUserStatic(): string
|
||||||
|
{
|
||||||
|
return $this->remoteUserStatic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
public function remoteUserMap(): array
|
||||||
|
{
|
||||||
|
return $this->remoteUserMap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-21
@@ -1,75 +1,85 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Data;
|
namespace App\Data;
|
||||||
|
|
||||||
|
use App\AppConstants;
|
||||||
use App\Enum\Scope;
|
use App\Enum\Scope;
|
||||||
use Symfony\Component\HttpFoundation\InputBag;
|
use Symfony\Component\HttpFoundation\InputBag;
|
||||||
|
|
||||||
/** when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
/** when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||||
final class Payload {
|
final class Payload
|
||||||
|
{
|
||||||
public string $id; /* session name, identifying who is logging in */
|
public string $id; /* session name, identifying who is logging in */
|
||||||
public string $token; /* TOTP, typically six digits */
|
public string $token; /* TOTP, typically six digits */
|
||||||
public string $nonce; /* random unique string, to block duplicate submissions */
|
public string $nonce; /* random unique string, to block duplicate submissions */
|
||||||
public bool $json; /* should we return json (for the login page) */
|
public bool $json; /* should we return json (for the login page) */
|
||||||
public Scope $scope; /* type of access being requested */
|
public Scope $scope; /* type of access being requested */
|
||||||
|
|
||||||
public static function decode(string $base64url): ?Payload {
|
public static function decode(string $base64url): ?self
|
||||||
|
{
|
||||||
/* convert the base64url into json string */
|
/* convert the base64url into json string */
|
||||||
$json = base64_decode(str_pad(strtr($base64url, '-_', '+/'),
|
$base64 = strtr($base64url, '-_', '+/');
|
||||||
strlen($base64url) % 4, '='
|
$base64 .= str_repeat('=', (4 - \strlen($base64) % 4) % 4);
|
||||||
), true);
|
$json = base64_decode($base64, true);
|
||||||
if ($json) {
|
if ($json) {
|
||||||
/* convert the json string into real data */
|
/* convert the json string into real data */
|
||||||
$data = json_decode($json);
|
$data = json_decode($json);
|
||||||
if (is_object($data)) {
|
if (\is_object($data)) {
|
||||||
return Payload::create($data);
|
return self::create($data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function load(InputBag $input): ?Payload {
|
public static function load(InputBag $input): ?self
|
||||||
|
{
|
||||||
/* convert form data into real data */
|
/* convert form data into real data */
|
||||||
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
|
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
|
||||||
return Payload::create((object)[
|
return self::create((object) [
|
||||||
'id' => $input->get('username'),
|
'id' => $input->get('username'),
|
||||||
'nonce' => $input->get('nonce'),
|
'nonce' => $input->get('nonce'),
|
||||||
'token' => $input->get('totp'),
|
'token' => $input->get('totp'),
|
||||||
'json' => false,
|
'json' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function create(object $data): ?Payload {
|
public static function create(object $data): ?self
|
||||||
|
{
|
||||||
/* if missing required fields id, nonce, or token */
|
/* if missing required fields id, nonce, or token */
|
||||||
if (strlen(trim($data->id ?? '')) < 1 ||
|
if ('' === trim($data->id ?? '')
|
||||||
strlen(trim($data->nonce ?? '')) < 1 ||
|
|| '' === trim($data->nonce ?? '')
|
||||||
strlen(trim($data->token ?? '')) < 1
|
|| '' === trim($data->token ?? '')
|
||||||
) {
|
) {
|
||||||
/* returns null as the input is invalid */
|
/* returns null as the input is invalid */
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* all input is limited */
|
/* all input is limited */
|
||||||
$payload = new Payload();
|
$payload = new self();
|
||||||
$payload->id = mb_substr(trim($data->id), 0, 128);
|
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||||
$payload->nonce = mb_substr(trim($data->nonce), 0, 128);
|
$payload->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||||
$payload->json = ($data->json ?? true);
|
$payload->json = ($data->json ?? true);
|
||||||
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
||||||
$payload->token = mb_substr(trim($data->token), 0, 128);
|
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||||
|
|
||||||
return Payload::constrict($payload);
|
return self::constrict($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toString(): string {
|
public function toString(): string
|
||||||
|
{
|
||||||
return json_encode($this);
|
return json_encode($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function constrict(Payload $payload): Payload {
|
private static function constrict(self $payload): self
|
||||||
|
{
|
||||||
/* When scope is None, json will be considered false. */
|
/* When scope is None, json will be considered false. */
|
||||||
if ($payload->scope === Scope::None) {
|
if (Scope::None === $payload->scope) {
|
||||||
$payload->json = false;
|
$payload->json = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls what value is sent in the Remote-User header on auth success.
|
||||||
|
*/
|
||||||
|
enum RemoteUserMode: string
|
||||||
|
{
|
||||||
|
/** Send the session id (current/default behaviour). */
|
||||||
|
case Session = 'session';
|
||||||
|
|
||||||
|
/** Send a fixed static string for all authenticated requests. */
|
||||||
|
case Static = 'static';
|
||||||
|
|
||||||
|
/** Look up the session id in a configured map and send the mapped value. */
|
||||||
|
case Mapped = 'mapped';
|
||||||
|
|
||||||
|
/** Do not send the Remote-User header at all. */
|
||||||
|
case None = 'none';
|
||||||
|
}
|
||||||
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Enum;
|
namespace App\Enum;
|
||||||
|
|
||||||
/** scope defines the context of how a session is persisted */
|
/** scope defines the context of how a session is persisted */
|
||||||
enum Scope: string {
|
enum Scope: string
|
||||||
|
{
|
||||||
case Cookie = 'cookie';
|
case Cookie = 'cookie';
|
||||||
case Ip = 'ip';
|
case Ip = 'ip';
|
||||||
case None = 'none';
|
case None = 'none';
|
||||||
|
|||||||
+10
-4
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
@@ -9,13 +10,15 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
final class Kernel extends BaseKernel {
|
class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
use MicroKernelTrait;
|
use MicroKernelTrait;
|
||||||
|
|
||||||
private PersistCache $persistCache;
|
private PersistCache $persistCache;
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function boot(): void {
|
public function boot(): void
|
||||||
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
$this->persistCache = $this->container->get(PersistCache::class);
|
$this->persistCache = $this->container->get(PersistCache::class);
|
||||||
@@ -23,9 +26,12 @@ final class Kernel extends BaseKernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function terminate(Request $request, Response $response): void {
|
public function terminate(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
$this->persistCache->persist();
|
$this->persistCache->persist();
|
||||||
|
} finally {
|
||||||
parent::terminate($request, $response);
|
parent::terminate($request, $response);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,62 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
|
|
||||||
use App\Service\DomainManager;
|
use App\ConfigBag;
|
||||||
|
use App\Service\DomainInterface;
|
||||||
use App\Trait\CookieNameTrait;
|
use App\Trait\CookieNameTrait;
|
||||||
use App\Trait\HasLoggerTrait;
|
use App\Trait\HasLoggerTrait;
|
||||||
use App\Trait\StringTrait;
|
use App\Trait\StringTrait;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
|
||||||
final readonly class AcceptListener {
|
final readonly class AcceptListener
|
||||||
|
{
|
||||||
use CookieNameTrait;
|
use CookieNameTrait;
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private CacheItemPoolInterface $sessionCache,
|
private CacheItemPoolInterface $sessionCache,
|
||||||
private DomainManager $domainManager,
|
private DomainInterface $domainManager,
|
||||||
) {}
|
private ConfigBag $config,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
|
||||||
#[AsEventListener(priority: 99)]
|
#[AsEventListener(priority: 99)]
|
||||||
public function onKernelRequest(RequestEvent $event): void {
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
/* check if they sent the correct preauth cookie */
|
/* check if they sent the correct preauth cookie */
|
||||||
$cookieName = $this->domainManager->authBase() ?$this->authCookieName() : $this->cookieName();
|
$cookieName = $this->sessionCookieName($this->domainManager);
|
||||||
if ($event->getRequest()->cookies->has($cookieName)) {
|
if (!$event->getRequest()->cookies->has($cookieName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$cookie = $event->getRequest()->cookies->get($cookieName);
|
$cookie = $event->getRequest()->cookies->get($cookieName);
|
||||||
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
||||||
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
|
|
||||||
/* cookie sent corresponds to valid existing session */
|
try {
|
||||||
$id = $this->sessionCache->getItem($cookieKey)->get();
|
if (!$cookie || !$this->sessionCache->hasItem($cookieKey)) {
|
||||||
$this->logger->debug("has valid cookie-session: $id");
|
return;
|
||||||
$event->setResponse(new Response("hi $id", headers: [
|
|
||||||
'Content-Type' => 'text/plain',
|
|
||||||
'Remote-User' => $id,
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* cookie sent corresponds to valid existing session */
|
||||||
|
$item = $this->sessionCache->getItem($cookieKey);
|
||||||
|
if (!$item->isHit()) {
|
||||||
|
/* race condition: item was removed between hasItem and getItem */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $item->get();
|
||||||
|
$this->logger->debug("has valid cookie-session: $id");
|
||||||
|
$event->setResponse($this->authSuccessResponse($id, $this->config));
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
/* cache failure — fail closed (don't authenticate) */
|
||||||
|
$this->logger->error("cache error in AcceptListener: {$e->getMessage()}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
@@ -9,32 +10,46 @@ use App\Trait\StringTrait;
|
|||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
|
||||||
final readonly class AllowListener {
|
final readonly class AllowListener
|
||||||
|
{
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private CacheItemPoolInterface $sessionCache,
|
private CacheItemPoolInterface $sessionCache,
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
) {}
|
) {
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
|
||||||
#[AsEventListener(priority: 88)]
|
|
||||||
public function onKernelRequest(RequestEvent $event): void {
|
|
||||||
if ($this->config->ipTtl() > 0) {
|
|
||||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
|
||||||
if ($this->sessionCache->hasItem($ipKey)) {
|
|
||||||
/* ip address corresponds to valid existing session */
|
|
||||||
$id = $this->sessionCache->getItem($ipKey)->get();
|
|
||||||
$this->logger->debug("has valid ip-session: $id");
|
|
||||||
$event->setResponse(new Response("hi $id", headers: [
|
|
||||||
'Content-Type' => 'text/plain',
|
|
||||||
'Remote-User' => $id,
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[AsEventListener(priority: 88)]
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
if ($this->config->ipTtl() <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!$this->sessionCache->hasItem($ipKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ip address corresponds to valid existing session */
|
||||||
|
$item = $this->sessionCache->getItem($ipKey);
|
||||||
|
if (!$item->isHit()) {
|
||||||
|
/* race condition: item was removed between hasItem and getItem */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $item->get();
|
||||||
|
$this->logger->debug("has valid ip-session: $id");
|
||||||
|
$event->setResponse($this->authSuccessResponse($id, $this->config));
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
/* cache failure — fail closed (don't authenticate) */
|
||||||
|
$this->logger->error("cache error in AllowListener: {$e->getMessage()}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
|
|
||||||
use App\ConfigBag;
|
use App\ConfigBag;
|
||||||
use App\Service\DomainManager;
|
use App\Service\DomainInterface;
|
||||||
use App\Trait\CookieNameTrait;
|
use App\Trait\CookieNameTrait;
|
||||||
use App\Trait\HasLoggerTrait;
|
use App\Trait\HasLoggerTrait;
|
||||||
use App\Trait\MakeNonceTrait;
|
use App\Trait\MakeNonceTrait;
|
||||||
@@ -18,30 +19,35 @@ use Twig\Error\LoaderError;
|
|||||||
use Twig\Error\RuntimeError;
|
use Twig\Error\RuntimeError;
|
||||||
use Twig\Error\SyntaxError;
|
use Twig\Error\SyntaxError;
|
||||||
|
|
||||||
final readonly class InterceptListener {
|
final readonly class InterceptListener
|
||||||
|
{
|
||||||
use CookieNameTrait;
|
use CookieNameTrait;
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
use MakeNonceTrait;
|
use MakeNonceTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
private DomainManager $domainManager,
|
private DomainInterface $domainManager,
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||||
#[AsEventListener(priority: 55)]
|
#[AsEventListener(priority: 55)]
|
||||||
public function onKernelRequest(RequestEvent $event): void {
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
/* by this point, we know that the request we have is:
|
/* by this point, we know that the request we have is:
|
||||||
* not already authorized, nor already rate-limited,
|
* not already authorized, nor already rate-limited,
|
||||||
* nor submitting login credentials; so redirect or present the login page now */
|
* nor submitting login credentials; so redirect or present the login page now */
|
||||||
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() &&
|
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost()
|
||||||
$this->domainManager->matchesAuth($event->getRequest()->getHost())
|
&& $this->domainManager->matchesAuth($event->getRequest()->getHost())
|
||||||
) {
|
) {
|
||||||
/* host matches base-domain of auth, but not on auth subdomain, redirect */
|
/* host matches base-domain of auth, but not on auth subdomain, redirect */
|
||||||
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
|
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
|
||||||
$event->setResponse(new Response('', Response::HTTP_SEE_OTHER,
|
$event->setResponse(new Response(
|
||||||
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
|
'',
|
||||||
|
Response::HTTP_SEE_OTHER,
|
||||||
|
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"],
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
||||||
@@ -50,25 +56,26 @@ final readonly class InterceptListener {
|
|||||||
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
||||||
]);
|
]);
|
||||||
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
||||||
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
|
$this->sessionCookieName($this->domainManager),
|
||||||
);
|
);
|
||||||
$event->setResponse($this->pruneInvalidCookie(new Response($content,
|
$event->setResponse($this->pruneInvalidCookie(new Response(
|
||||||
Response::HTTP_UNAUTHORIZED, ['Content-Type' => 'text/html']
|
$content,
|
||||||
|
Response::HTTP_UNAUTHORIZED,
|
||||||
|
['Content-Type' => 'text/html'],
|
||||||
), $hasCookie, $event->getRequest()->getHost()));
|
), $hasCookie, $event->getRequest()->getHost()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response {
|
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response
|
||||||
|
{
|
||||||
if ($hasCookie) {
|
if ($hasCookie) {
|
||||||
/* input here must match LoginListener::setCookie() */
|
|
||||||
$response->headers->clearCookie(
|
$response->headers->clearCookie(
|
||||||
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
|
$this->sessionCookieName($this->domainManager),
|
||||||
'/',
|
'/',
|
||||||
/* if using central auth, only set the domain if the host matches */
|
$this->sessionCookieDomain($this->domainManager, $host),
|
||||||
$this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
|
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Cookie::SAMESITE_STRICT
|
Cookie::SAMESITE_STRICT,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
|
|
||||||
use App\ConfigBag;
|
use App\ConfigBag;
|
||||||
use App\Data\Payload;
|
use App\Data\Payload;
|
||||||
use App\Service\DomainManager;
|
use App\Service\DomainInterface;
|
||||||
use App\Service\LoginManager;
|
use App\Service\LoginInterface;
|
||||||
use App\Trait\CookieNameTrait;
|
use App\Trait\CookieNameTrait;
|
||||||
use App\Trait\HasLoggerTrait;
|
use App\Trait\HasLoggerTrait;
|
||||||
use App\Trait\MakeNonceTrait;
|
use App\Trait\MakeNonceTrait;
|
||||||
@@ -23,7 +24,17 @@ use Twig\Error\LoaderError;
|
|||||||
use Twig\Error\RuntimeError;
|
use Twig\Error\RuntimeError;
|
||||||
use Twig\Error\SyntaxError;
|
use Twig\Error\SyntaxError;
|
||||||
|
|
||||||
final readonly class LoginListener {
|
/**
|
||||||
|
* Handles login attempts via X-Preauth header (AJAX) or POST form submission.
|
||||||
|
*
|
||||||
|
* CSRF Protection: The nonce field serves as CSRF protection for the POST form
|
||||||
|
* path. Nonces are server-generated, single-use, and have a 120-second TTL.
|
||||||
|
* An attacker cannot forge a POST request without first loading the login page
|
||||||
|
* to obtain a valid nonce, which requires being on the auth subdomain.
|
||||||
|
* For the AJAX (header) path, the nonce is embedded in the base64url payload.
|
||||||
|
*/
|
||||||
|
final readonly class LoginListener
|
||||||
|
{
|
||||||
use CookieNameTrait;
|
use CookieNameTrait;
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
use MakeNonceTrait;
|
use MakeNonceTrait;
|
||||||
@@ -34,8 +45,8 @@ final readonly class LoginListener {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||||
private DomainManager $domainManager,
|
private DomainInterface $domainManager,
|
||||||
private LoginManager $loginManager,
|
private LoginInterface $loginManager,
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
) {
|
) {
|
||||||
$this->rateLimiter = $rateLimiter;
|
$this->rateLimiter = $rateLimiter;
|
||||||
@@ -43,7 +54,8 @@ final readonly class LoginListener {
|
|||||||
|
|
||||||
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
||||||
#[AsEventListener(priority: 66)]
|
#[AsEventListener(priority: 66)]
|
||||||
public function onKernelRequest(RequestEvent $event): void {
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
$payload = null;
|
$payload = null;
|
||||||
$response = null;
|
$response = null;
|
||||||
|
|
||||||
@@ -51,8 +63,8 @@ final readonly class LoginListener {
|
|||||||
/* if request contains our "X-Preauth" header */
|
/* if request contains our "X-Preauth" header */
|
||||||
$data = $event->getRequest()->headers->get($this->headerName());
|
$data = $event->getRequest()->headers->get($this->headerName());
|
||||||
$payload = Payload::decode($data);
|
$payload = Payload::decode($data);
|
||||||
} else if ($event->getRequest()->isMethod(Request::METHOD_POST) &&
|
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST)
|
||||||
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
&& $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
||||||
) {
|
) {
|
||||||
/* if request is a POST to the auth-subdomain */
|
/* if request is a POST to the auth-subdomain */
|
||||||
$payload = Payload::load($event->getRequest()->getPayload());
|
$payload = Payload::load($event->getRequest()->getPayload());
|
||||||
@@ -68,6 +80,7 @@ final readonly class LoginListener {
|
|||||||
/* token or backup-code authentication was successful */
|
/* token or backup-code authentication was successful */
|
||||||
if ($response) {
|
if ($response) {
|
||||||
$event->setResponse($response);
|
$event->setResponse($response);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,18 +89,24 @@ final readonly class LoginListener {
|
|||||||
$limitReached = $this->logFailure($event->getRequest());
|
$limitReached = $this->logFailure($event->getRequest());
|
||||||
|
|
||||||
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
|
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
|
||||||
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
|
$event->setResponse($this->makeFailedResponse(
|
||||||
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
|
$limitReached,
|
||||||
|
$payload?->json ?? true,
|
||||||
|
$event->getRequest()->getHost(),
|
||||||
|
$this->makeCacheKey($payload?->id ?? ''),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function logFailure(Request $request): bool {
|
private function logFailure(Request $request): bool
|
||||||
|
{
|
||||||
$limiter = $this->rateLimiter->create($request->getClientIp());
|
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||||
return ($limiter->consume(1)->getRemainingTokens() < 1);
|
|
||||||
|
return $limiter->consume(1)->getRemainingTokens() < 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||||
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response {
|
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
|
||||||
|
{
|
||||||
if ($limited) {
|
if ($limited) {
|
||||||
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
|
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
|
||||||
: Response::HTTP_TOO_MANY_REQUESTS;
|
: Response::HTTP_TOO_MANY_REQUESTS;
|
||||||
@@ -112,6 +131,6 @@ final readonly class LoginListener {
|
|||||||
$content = $this->twig->render('login.html.twig', $answer);
|
$content = $this->twig->render('login.html.twig', $answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response($content, $status, ["Content-Type" => $contentType]);
|
return new Response($content, $status, ['Content-Type' => $contentType]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listener;
|
||||||
|
|
||||||
|
use App\Service\DomainInterface;
|
||||||
|
use App\Service\PublicPathMatcherInterface;
|
||||||
|
use App\Trait\HasLoggerTrait;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
|
use Twig\Error\RuntimeError;
|
||||||
|
use Twig\Error\SyntaxError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows rate-limited unauthenticated access to configured public paths.
|
||||||
|
*
|
||||||
|
* Runs at priority 84 — after AcceptListener (99) and AllowListener (88)
|
||||||
|
* so authenticated users bypass this listener entirely, but before
|
||||||
|
* RejectListener (77) and LoginListener (66) so public traffic is not
|
||||||
|
* subject to the login rate limiter.
|
||||||
|
*
|
||||||
|
* When the request path matches a configured public path pattern:
|
||||||
|
* - If within rate limit → 200 OK (no Remote-User header)
|
||||||
|
* - If over rate limit → 429 Too Many Requests with Retry-After header
|
||||||
|
*
|
||||||
|
* Non-matching paths fall through to the normal auth flow.
|
||||||
|
*/
|
||||||
|
final readonly class PublicAccessListener
|
||||||
|
{
|
||||||
|
use HasLoggerTrait;
|
||||||
|
|
||||||
|
private RateLimiterFactoryInterface $rateLimiter;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private PublicPathMatcherInterface $pathMatcher,
|
||||||
|
private DomainInterface $domainManager,
|
||||||
|
private Environment $twig,
|
||||||
|
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||||
|
) {
|
||||||
|
$this->rateLimiter = $rateLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws SyntaxError|RuntimeError|LoaderError */
|
||||||
|
#[AsEventListener(priority: 84)]
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
if ($this->pathMatcher->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $event->getRequest();
|
||||||
|
$host = $request->getHost();
|
||||||
|
$path = $request->getPathInfo();
|
||||||
|
|
||||||
|
// Never treat the auth subdomain itself as public
|
||||||
|
if ($this->domainManager->getAuthSubdomain() === $host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->pathMatcher->matches($host, $path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path is public — apply rate limiting
|
||||||
|
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||||
|
$limit = $limiter->consume(1);
|
||||||
|
|
||||||
|
if ($limit->isAccepted()) {
|
||||||
|
$this->logger->debug("public access granted: {$request->getClientIp()} -> $path");
|
||||||
|
$event->setResponse(new Response(
|
||||||
|
'',
|
||||||
|
Response::HTTP_OK,
|
||||||
|
[
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
'Retry-After' => (string) $limit->getRemainingTokens(),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$retryAfter = $limit->getRetryAfter()?->getTimestamp() - time();
|
||||||
|
$retryAfter = max(1, $retryAfter);
|
||||||
|
|
||||||
|
$this->logger->debug("public access rate-limited: {$request->getClientIp()} -> $path");
|
||||||
|
$html = $this->twig->render('error.html.twig');
|
||||||
|
$event->setResponse(new Response(
|
||||||
|
$html,
|
||||||
|
Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
|
[
|
||||||
|
'Content-Type' => 'text/html',
|
||||||
|
'Retry-After' => (string) $retryAfter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
@@ -8,15 +9,16 @@ use App\Trait\HasLoggerTrait;
|
|||||||
use App\Trait\StringTrait;
|
use App\Trait\StringTrait;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
use Twig\Error\LoaderError;
|
use Twig\Error\LoaderError;
|
||||||
use Twig\Error\RuntimeError;
|
use Twig\Error\RuntimeError;
|
||||||
use Twig\Error\SyntaxError;
|
use Twig\Error\SyntaxError;
|
||||||
|
|
||||||
final readonly class RejectListener {
|
final readonly class RejectListener
|
||||||
|
{
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
@@ -32,15 +34,18 @@ final readonly class RejectListener {
|
|||||||
|
|
||||||
/** @throws SyntaxError|RuntimeError|LoaderError */
|
/** @throws SyntaxError|RuntimeError|LoaderError */
|
||||||
#[AsEventListener(priority: 77)]
|
#[AsEventListener(priority: 77)]
|
||||||
public function onKernelRequest(RequestEvent $event): void {
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
/* check if they have made too many failed login attempts */
|
/* check if they have made too many failed login attempts */
|
||||||
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
|
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
|
||||||
if ($limiter->consume(0)->getRemainingTokens() < 1) {
|
if ($limiter->consume(0)->getRemainingTokens() < 1) {
|
||||||
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
|
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
|
||||||
$html = $this->twig->render('error.html.twig');
|
$html = $this->twig->render('error.html.twig');
|
||||||
$event->setResponse(new Response($html, ($this->config->teapot()
|
$event->setResponse(new Response(
|
||||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
$html,
|
||||||
['Content-Type' => 'text/html']
|
$this->config->teapot()
|
||||||
|
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
|
['Content-Type' => 'text/html'],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listener;
|
||||||
|
|
||||||
|
use App\Service\DomainInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds security-related HTTP response headers to all responses.
|
||||||
|
* These headers help protect against XSS, clickjacking, MIME-type
|
||||||
|
* sniffing, and referrer leakage.
|
||||||
|
*/
|
||||||
|
final readonly class SecurityHeadersListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DomainInterface $domainManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsEventListener(priority: 0)]
|
||||||
|
public function onKernelResponse(ResponseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
$headers = $response->headers;
|
||||||
|
|
||||||
|
/* prevent MIME-type sniffing */
|
||||||
|
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
|
/* prevent clickjacking — this app is never framed */
|
||||||
|
$headers->set('X-Frame-Options', 'DENY');
|
||||||
|
|
||||||
|
/* control referrer information sent to other sites */
|
||||||
|
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
|
||||||
|
/* Content-Security-Policy — the login page uses inline styles
|
||||||
|
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
||||||
|
* for those. No external resources are loaded.
|
||||||
|
*
|
||||||
|
* When subdomain redirection is off (or the request is not on
|
||||||
|
* the auth subdomain), the login form is served inline on the
|
||||||
|
* protected host and submission is performed via a same-origin
|
||||||
|
* fetch() call in _script.html.twig. That fetch is blocked by
|
||||||
|
* the default 'none' policy, so we add connect-src 'self' only
|
||||||
|
* in that case — the least privilege needed to make the form
|
||||||
|
* work. On the auth subdomain the form POSTs normally and no
|
||||||
|
* inline script is included, so the stricter policy applies. */
|
||||||
|
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
|
||||||
|
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
|
||||||
|
|
||||||
|
if ($inlineScript) {
|
||||||
|
$csp .= " connect-src 'self';";
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers->set('Content-Security-Policy', $csp);
|
||||||
|
|
||||||
|
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
||||||
|
$headers->set('Strict-Transport-Security', 'max-age=31536000');
|
||||||
|
|
||||||
|
/* Prevent any part of the login flow from being cached: the login
|
||||||
|
* page, failed logins, redirects, and rate-limit/error pages must
|
||||||
|
* never be stored or replayed by the browser or an intermediate
|
||||||
|
* cache — older Safari builds in particular may otherwise resurrect
|
||||||
|
* a stale pre-auth response, appearing to log the user out after a
|
||||||
|
* refresh or showing a previous session after logging in again.
|
||||||
|
*
|
||||||
|
* Only non-2xx responses are touched: the 2xx responses that grant
|
||||||
|
* access ("already authenticated" or public) are consumed by the
|
||||||
|
* reverse proxy's forward_auth check before reaching the browser,
|
||||||
|
* and the protected service's own cache headers must remain
|
||||||
|
* untouched. */
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
|
||||||
|
$headers->set('Pragma', 'no-cache');
|
||||||
|
$headers->set('Expires', '0');
|
||||||
|
$headers->set('Surrogate-Control', 'no-store');
|
||||||
|
$headers->set('Vary', '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-32
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
@@ -10,7 +11,8 @@ use Psr\Cache\InvalidArgumentException;
|
|||||||
|
|
||||||
/* we must *NOT* store the key-list item or values within this object
|
/* we must *NOT* store the key-list item or values within this object
|
||||||
* because it can change from outside this object instance */
|
* because it can change from outside this object instance */
|
||||||
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||||
|
{
|
||||||
private const string KEY_LIST = '__key_list';
|
private const string KEY_LIST = '__key_list';
|
||||||
private const string CHANGE_LIST = '__chg_list';
|
private const string CHANGE_LIST = '__chg_list';
|
||||||
public const int UPDATED = 1;
|
public const int UPDATED = 1;
|
||||||
@@ -19,11 +21,12 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
|||||||
private CacheItemPoolInterface $cache;
|
private CacheItemPoolInterface $cache;
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(CacheItemPoolInterface $cache) {
|
public function __construct(CacheItemPoolInterface $cache)
|
||||||
|
{
|
||||||
$this->cache = $cache;
|
$this->cache = $cache;
|
||||||
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if ( ! $item->isHit()) {
|
if (!$item->isHit()) {
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -31,7 +34,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
private function initialize(): void {
|
private function initialize(): void
|
||||||
|
{
|
||||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||||
$keyList->set([]);
|
$keyList->set([]);
|
||||||
@@ -42,51 +46,66 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function getKeys(): array {
|
public function getKeys(): array
|
||||||
|
{
|
||||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||||
|
|
||||||
return array_keys($keyList->get() ?? []);
|
return array_keys($keyList->get() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function getChanges(): array {
|
public function getChanges(): array
|
||||||
|
{
|
||||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||||
|
|
||||||
return $changeList->get() ?? [];
|
return $changeList->get() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function markClean(): void {
|
public function markClean(): void
|
||||||
|
{
|
||||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||||
$changeList->set([]);
|
$changeList->set([]);
|
||||||
$this->cache->save($changeList);
|
$this->cache->save($changeList);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getItem(string $key): CacheItemInterface {
|
/** @throws InvalidArgumentException */
|
||||||
|
public function getItem(string $key): CacheItemInterface
|
||||||
|
{
|
||||||
return $this->cache->getItem($key);
|
return $this->cache->getItem($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return CacheItemInterface[]
|
/** @return CacheItemInterface[]
|
||||||
* @throws InvalidArgumentException */
|
* @throws InvalidArgumentException */
|
||||||
public function getItems(array $keys = []): iterable {
|
public function getItems(array $keys = []): iterable
|
||||||
|
{
|
||||||
return $this->cache->getItems($keys);
|
return $this->cache->getItems($keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasItem(string $key): bool {
|
/** @throws InvalidArgumentException */
|
||||||
|
public function hasItem(string $key): bool
|
||||||
|
{
|
||||||
return $this->cache->hasItem($key);
|
return $this->cache->hasItem($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function clear(): bool {
|
public function clear(): bool
|
||||||
|
{
|
||||||
/* only bother clearing the pool if it is not empty */
|
/* only bother clearing the pool if it is not empty */
|
||||||
if ( ! empty($this->getKeys())) {
|
if (!empty($this->getKeys())) {
|
||||||
$response = $this->cache->clear();
|
$response = $this->cache->clear();
|
||||||
|
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteItem(string $key): bool {
|
/** @throws InvalidArgumentException */
|
||||||
|
public function deleteItem(string $key): bool
|
||||||
|
{
|
||||||
$this->isValid($key);
|
$this->isValid($key);
|
||||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||||
$keyValues = $keyList->get();
|
$keyValues = $keyList->get();
|
||||||
@@ -94,21 +113,23 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
|||||||
unset($keyValues[$key]);
|
unset($keyValues[$key]);
|
||||||
$keyList->set($keyValues);
|
$keyList->set($keyValues);
|
||||||
$this->cache->saveDeferred($keyList);
|
$this->cache->saveDeferred($keyList);
|
||||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
$this->logChange($key, self::REMOVED);
|
||||||
$this->cache->commit();
|
$this->cache->commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->cache->deleteItem($key);
|
return $this->cache->deleteItem($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteItems(array $keys): bool {
|
/** @throws InvalidArgumentException */
|
||||||
|
public function deleteItems(array $keys): bool
|
||||||
|
{
|
||||||
$this->allValid($keys);
|
$this->allValid($keys);
|
||||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||||
$keyValues = $keyList->get();
|
$keyValues = $keyList->get();
|
||||||
foreach ($keys as $key) {
|
foreach ($keys as $key) {
|
||||||
if (isset($keyValues[$key])) {
|
if (isset($keyValues[$key])) {
|
||||||
unset($keyValues[$key]);
|
unset($keyValues[$key]);
|
||||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
$this->logChange($key, self::REMOVED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$keyList->set($keyValues);
|
$keyList->set($keyValues);
|
||||||
@@ -119,23 +140,30 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function save(CacheItemInterface $item): bool {
|
public function save(CacheItemInterface $item): bool
|
||||||
|
{
|
||||||
$this->update($item);
|
$this->update($item);
|
||||||
|
|
||||||
return $this->cache->save($item);
|
return $this->cache->save($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function saveDeferred(CacheItemInterface $item): bool {
|
public function saveDeferred(CacheItemInterface $item): bool
|
||||||
|
{
|
||||||
$this->update($item);
|
$this->update($item);
|
||||||
|
|
||||||
return $this->cache->saveDeferred($item);
|
return $this->cache->saveDeferred($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function commit(): bool {
|
/** @throws InvalidArgumentException */
|
||||||
|
public function commit(): bool
|
||||||
|
{
|
||||||
return $this->cache->commit();
|
return $this->cache->commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException|OutOfBoundsException */
|
/** @throws InvalidArgumentException|OutOfBoundsException */
|
||||||
private function update(CacheItemInterface $item): void {
|
private function update(CacheItemInterface $item): void
|
||||||
|
{
|
||||||
$this->isValid($item->getKey());
|
$this->isValid($item->getKey());
|
||||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||||
$keyValues = $keyList->get();
|
$keyValues = $keyList->get();
|
||||||
@@ -147,27 +175,26 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws OutOfBoundsException */
|
/** @throws OutOfBoundsException */
|
||||||
private function isValid(string $key): void {
|
private function isValid(string $key): void
|
||||||
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
|
{
|
||||||
throw new OutOfBoundsException(
|
if (self::KEY_LIST === $key || self::CHANGE_LIST === $key) {
|
||||||
'Can not modify the private key or change lists'
|
throw new OutOfBoundsException('Can not modify the private key or change lists');
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws OutOfBoundsException */
|
/** @throws OutOfBoundsException */
|
||||||
private function allValid(array $keys): void {
|
private function allValid(array $keys): void
|
||||||
if (in_array(self::KEY_LIST, $keys, true) ||
|
{
|
||||||
in_array(self::CHANGE_LIST, $keys, true)
|
if (\in_array(self::KEY_LIST, $keys, true)
|
||||||
|
|| \in_array(self::CHANGE_LIST, $keys, true)
|
||||||
) {
|
) {
|
||||||
throw new OutOfBoundsException(
|
throw new OutOfBoundsException('Can not modify the private key or change lists');
|
||||||
'Can not modify the private key or change lists'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void {
|
private function logChange(string $key, int $code = self::UPDATED): void
|
||||||
|
{
|
||||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||||
$changeValues = $changeList->get();
|
$changeValues = $changeList->get();
|
||||||
$changeValues[$key] = $code;
|
$changeValues[$key] = $code;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
@@ -9,7 +10,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
|||||||
|
|
||||||
/* need autoconfigure so we get it from the service container in Kernel->boot() */
|
/* need autoconfigure so we get it from the service container in Kernel->boot() */
|
||||||
#[Autoconfigure(public: true)]
|
#[Autoconfigure(public: true)]
|
||||||
final readonly class PersistCache {
|
final readonly class PersistCache
|
||||||
|
{
|
||||||
private MonitorCacheKeys $sessionCache;
|
private MonitorCacheKeys $sessionCache;
|
||||||
private MonitorCacheKeys $sessionStorage;
|
private MonitorCacheKeys $sessionStorage;
|
||||||
|
|
||||||
@@ -23,7 +25,8 @@ final readonly class PersistCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function boot(): void {
|
public function boot(): void
|
||||||
|
{
|
||||||
/* the caches are considered warm as soon as they are not empty */
|
/* the caches are considered warm as soon as they are not empty */
|
||||||
if (empty($this->sessionCache->getKeys())) {
|
if (empty($this->sessionCache->getKeys())) {
|
||||||
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
|
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
|
||||||
@@ -36,7 +39,8 @@ final readonly class PersistCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function persist(): void {
|
public function persist(): void
|
||||||
|
{
|
||||||
/* we only need to persist the changes made to the cache (if any) */
|
/* we only need to persist the changes made to the cache (if any) */
|
||||||
$changes = $this->sessionCache->getChanges();
|
$changes = $this->sessionCache->getChanges();
|
||||||
if ($changes) {
|
if ($changes) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
|
||||||
|
/** backup-codes are case‑insensitive alphanumeric strings
|
||||||
|
* they are single-use and marked as used after successful authentication */
|
||||||
|
interface BackupCodeInterface
|
||||||
|
{
|
||||||
|
/** generate a set of backup-codes and return them.
|
||||||
|
* @param int $count Number of codes to generate
|
||||||
|
*
|
||||||
|
* @return string[] Generated backup codes
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException|Exception */
|
||||||
|
public function generate(int $count = 10): array;
|
||||||
|
|
||||||
|
/** @throws InvalidArgumentException */
|
||||||
|
public function expire(): void;
|
||||||
|
|
||||||
|
/** check if backup-code is valid and mark it as used.
|
||||||
|
* @param string $code Code supplied by the client
|
||||||
|
*
|
||||||
|
* @return bool true if the code is valid and unused
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException */
|
||||||
|
public function verifyAndConsume(string $code): bool;
|
||||||
|
}
|
||||||
@@ -1,94 +1,108 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\AppConstants;
|
||||||
use App\MonitorCacheKeys;
|
use App\MonitorCacheKeys;
|
||||||
|
use App\Trait\GetTotpTrait;
|
||||||
use App\Trait\HasLoggerTrait;
|
use App\Trait\HasLoggerTrait;
|
||||||
use App\Trait\StringTrait;
|
use App\Trait\StringTrait;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use App\Trait\GetTotpTrait;
|
|
||||||
|
|
||||||
/** backup-codes are case‑insensitive alphanumeric strings
|
/** backup-codes are case‑insensitive alphanumeric strings
|
||||||
* they are single-use and marked as used after successful authentication
|
* they are single-use and marked as used after successful authentication */
|
||||||
*/
|
final readonly class BackupCodeManager implements BackupCodeInterface
|
||||||
final readonly class BackupCodeManager {
|
{
|
||||||
use GetTotpTrait;
|
use GetTotpTrait;
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
private const int DEFAULT_COUNT = 10;
|
private const int DEFAULT_COUNT = 10;
|
||||||
/* php base_convert() will break if given too long of an input */
|
/* php base_convert() will break if given too long of an input */
|
||||||
const int MAX_LENGTH = 64;
|
public const int MAX_LENGTH = 64;
|
||||||
|
|
||||||
private CacheItemPoolInterface $sessionCache;
|
private CacheItemPoolInterface $sessionCache;
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(CacheItemPoolInterface $sessionCache) {
|
public function __construct(CacheItemPoolInterface $sessionCache)
|
||||||
|
{
|
||||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** generate a set of backup-codes and return them
|
/** generate a set of backup-codes and return them.
|
||||||
* @param int $count Number of codes to generate
|
* @param int $count Number of codes to generate
|
||||||
|
*
|
||||||
* @return string[] Generated backup codes
|
* @return string[] Generated backup codes
|
||||||
|
*
|
||||||
* @throws InvalidArgumentException|Exception */
|
* @throws InvalidArgumentException|Exception */
|
||||||
public function generate(int $count = self::DEFAULT_COUNT): array {
|
public function generate(int $count = self::DEFAULT_COUNT): array
|
||||||
|
{
|
||||||
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
|
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
|
||||||
$codes = [];
|
$codes = [];
|
||||||
for ($i = 0; $i < $count; $i++) {
|
for ($i = 0; $i < $count; ++$i) {
|
||||||
/* output is alphanumeric string of given length */
|
/* output is alphanumeric string of given length */
|
||||||
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
|
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
|
||||||
random_bytes($length)
|
random_bytes($length),
|
||||||
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
|
), 16, 36), 0, $length), $length, '0', \STR_PAD_LEFT));
|
||||||
}
|
}
|
||||||
$this->saveCodes($codes);
|
$this->saveCodes($codes);
|
||||||
$this->logger->info("generated {$count} backup codes");
|
$this->logger->info("generated {$count} backup codes");
|
||||||
|
|
||||||
return $codes;
|
return $codes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function expire(): void {
|
public function expire(): void
|
||||||
|
{
|
||||||
$itemsToRemove = [];
|
$itemsToRemove = [];
|
||||||
foreach ($this->sessionCache->getKeys() as $key) {
|
foreach ($this->sessionCache->getKeys() as $key) {
|
||||||
if (str_starts_with($key, 'backup_')) {
|
if (str_starts_with($key, 'backup_')) {
|
||||||
$itemsToRemove[] = $key;
|
$itemsToRemove[] = $key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count($itemsToRemove) > 0) {
|
if (\count($itemsToRemove) > 0) {
|
||||||
$this->sessionCache->deleteItems($itemsToRemove);
|
$this->sessionCache->deleteItems($itemsToRemove);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** check if backup-code is valid and mark it as used
|
/** check if backup-code is valid and mark it as used.
|
||||||
* @param string $code Code supplied by the client
|
* @param string $code Code supplied by the client
|
||||||
|
*
|
||||||
* @return bool true if the code is valid and unused
|
* @return bool true if the code is valid and unused
|
||||||
|
*
|
||||||
* @throws InvalidArgumentException */
|
* @throws InvalidArgumentException */
|
||||||
public function verifyAndConsume(string $code): bool {
|
public function verifyAndConsume(string $code): bool
|
||||||
|
{
|
||||||
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
|
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
|
||||||
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
|
$backupKey = 'backup_'.preg_replace('/[^a-z0-9]+/', '', strtolower($code));
|
||||||
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
|
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
|
||||||
$this->logger->debug("checking backup code '{$backupKey}': " . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
|
$this->logger->debug('checking backup code: '.($backupItem->isHit() ? 'HIT & ' : 'miss & ').($backupItem->get() ? 'VALID' : 'invalid'));
|
||||||
if ($backupItem->isHit() && $backupItem->get()) {
|
if ($backupItem->isHit() && $backupItem->get()) {
|
||||||
$this->logger->debug("valid backup code");
|
$this->logger->debug('valid backup code');
|
||||||
/* mark backup code as spent */
|
/* mark backup code as spent */
|
||||||
$backupItem->set(false); /* used */
|
$backupItem->set(false); /* used */
|
||||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
/* 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 */
|
* we want this to keep forever, so a few hundred years should do it */
|
||||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d', '2999-12-31'
|
'Y-m-d',
|
||||||
|
AppConstants::FAR_FUTURE_DATE,
|
||||||
));
|
));
|
||||||
$this->sessionCache->save($backupItem);
|
$this->sessionCache->save($backupItem);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
private function saveCodes(array $codes): void {
|
private function saveCodes(array $codes): void
|
||||||
|
{
|
||||||
foreach ($codes as $code) {
|
foreach ($codes as $code) {
|
||||||
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
|
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
|
||||||
/* mark backup code as ready */
|
/* mark backup code as ready */
|
||||||
@@ -96,7 +110,8 @@ final readonly class BackupCodeManager {
|
|||||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
/* 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 */
|
* we want this to keep forever, so a few hundred years should do it */
|
||||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d', '2999-12-31'
|
'Y-m-d',
|
||||||
|
AppConstants::FAR_FUTURE_DATE,
|
||||||
));
|
));
|
||||||
$this->sessionCache->saveDeferred($backupItem);
|
$this->sessionCache->saveDeferred($backupItem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
interface DomainInterface
|
||||||
|
{
|
||||||
|
/** IE: "auth.example.com" or null if not using a separate subdomain.
|
||||||
|
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||||
|
public function getAuthSubdomain(): ?string;
|
||||||
|
|
||||||
|
/** check if given url is an acceptable url for redirection.
|
||||||
|
* @param string $url Where we are thinking of sending the user
|
||||||
|
*
|
||||||
|
* @return bool Returns true if it is acceptable to send the user there */
|
||||||
|
public function validReturn(string $url): bool;
|
||||||
|
|
||||||
|
/** check if host-base matches auth-base.
|
||||||
|
* @return bool returns true if and only if host matches base domain of auth */
|
||||||
|
public function matchesAuth(string $host): bool;
|
||||||
|
|
||||||
|
/** IE: "example.com" if central auth is something like "auth.example.com".
|
||||||
|
* @return string|null returns base domain if we are doing central auth */
|
||||||
|
public function authBase(): ?string;
|
||||||
|
}
|
||||||
+133
-37
@@ -1,28 +1,109 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
final readonly class DomainManager {
|
final readonly class DomainManager implements DomainInterface
|
||||||
|
{
|
||||||
/* top-level-domains which are known to have multiple parts */
|
/* top-level-domains which are known to have multiple parts */
|
||||||
private const array TLD = [
|
private const array TLD = [
|
||||||
'ai' => ['com','net','off','org'],
|
'ai' => ['com', 'net', 'off', 'org'],
|
||||||
'am' => ['radio'],
|
'am' => ['radio'],
|
||||||
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za'],
|
'at' => ['ac', 'co', 'gv', 'or'],
|
||||||
|
'au' => ['com', 'net', 'org', 'edu', 'gov', 'asn', 'id'],
|
||||||
|
'az' => ['com', 'net', 'org'],
|
||||||
|
'bd' => ['com', 'net', 'org', 'gov', 'mil', 'ac'],
|
||||||
|
'br' => ['com', 'net', 'org', 'gov', 'mil', 'eco', 'emp', 'g12', 'ind', 'inf', 'rec', 'tur', 'tv', 'edu', 'far', 'gov', 'gru', 'jor', 'leg', 'lec', 'med', 'nom', 'not', 'ppg', 'pro', 'psi', 'pub', 'slg', 'srv', 'tec', 'tmp', 'vip', 'vlog', 'wiki', 'zlg'],
|
||||||
|
'by' => ['com', 'net', 'org', 'gov', 'mil', 'of'],
|
||||||
|
'ca' => ['ab', 'bc', 'mb', 'nb', 'nf', 'nl', 'ns', 'nt', 'nu', 'on', 'pe', 'qc', 'sk', 'yk'],
|
||||||
|
'cc' => [],
|
||||||
|
'cn' => ['com', 'net', 'org', 'gov', 'edu', 'ac', 'bj', 'sh', 'tj', 'cq', 'he', 'sx', 'nm', 'ln', 'jl', 'hl', 'js', 'zj', 'ah', 'fj', 'jx', 'sd', 'ha', 'hb', 'hn', 'gd', 'gx', 'hi', 'sc', 'gz', 'yn', 'sn', 'gs', 'qh', 'nx', 'xj', 'tw', 'hk', 'mo'],
|
||||||
|
'co' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'arts', 'firm', 'info', 'int', 'nom', 'rec', 'web'],
|
||||||
|
'com' => ['br', 'cn', 'co', 'de', 'eu', 'gr', 'it', 'jpn', 'mex', 'ru', 'sa', 'uk', 'us', 'za', 'au', 'bh', 'bo', 'cn', 'ec', 'eg', 'gt', 'hk', 'hn', 'il', 'in', 'jp', 'kr', 'kw', 'lb', 'lv', 'my', 'mx', 'ng', 'ni', 'np', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pr', 'py', 'sa', 'sg', 'sv', 'tr', 'tw', 'ua', 'uy', 've', 'vn', 'ye'],
|
||||||
'de' => ['com'],
|
'de' => ['com'],
|
||||||
|
'dk' => ['co'],
|
||||||
|
'ec' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'fin', 'med', 'pro'],
|
||||||
|
'ee' => ['com', 'org', 'pri'],
|
||||||
|
'eg' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
|
'es' => ['com', 'nom', 'org', 'edu', 'gob'],
|
||||||
|
'eu' => [],
|
||||||
|
'fi' => ['aland'],
|
||||||
'fm' => ['radio'],
|
'fm' => ['radio'],
|
||||||
'gg' => ['co','net','org'],
|
'fr' => ['com', 'nom', 'tm', 'asso', 'gouv', 'pol'],
|
||||||
'in' => ['co','firm','gen','ind','net','org'],
|
'ge' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
|
||||||
'je' => ['co','net','org'],
|
'gg' => ['co', 'net', 'org'],
|
||||||
'mx' => ['com','net','org'],
|
'gr' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
'net' => ['gb','hu','in','jp','se','uk'],
|
'hk' => ['com', 'net', 'org', 'gov', 'edu', 'idv'],
|
||||||
'nz' => ['co','net','org'],
|
'hu' => ['co', '2000', 'privat', 'sport', 'tm', 'erotica', 'sex', 'video', 'info', 'org', 'net', 'gov', 'edu', 'mil', 'press', 'biz'],
|
||||||
'org' => ['ae','us'],
|
'id' => ['ac', 'biz', 'co', 'desa', 'go', 'mil', 'my', 'net', 'or', 'sch', 'web'],
|
||||||
'ph' => ['com','net','org'],
|
'ie' => ['gov'],
|
||||||
'se' => ['com'],
|
'il' => ['ac', 'co', 'gov', 'idf', 'k12', 'muni', 'net', 'org'],
|
||||||
'uk' => ['co','me','org'],
|
'in' => ['co', 'firm', 'gen', 'ind', 'net', 'org', 'ac', 'edu', 'res', 'gov', 'mil'],
|
||||||
|
'iq' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
|
'ir' => ['ac', 'co', 'gov', 'id', 'net', 'org', 'sch'],
|
||||||
|
'is' => ['net', 'com', 'org', 'edu', 'gov', 'int'],
|
||||||
|
'it' => ['ab', 'ag', 'al', 'an', 'ao', 'ap', 'aq', 'ar', 'at', 'av', 'ba', 'bg', 'bi', 'bl', 'bn', 'bo', 'br', 'bs', 'bt', 'bz', 'ca', 'cb', 'ce', 'ch', 'cl', 'cn', 'co', 'cr', 'cs', 'ct', 'cz', 'en', 'fc', 'fe', 'fg', 'fi', 'fm', 'fr', 'ge', 'go', 'gr', 'im', 'is', 'kr', 'lc', 'le', 'li', 'lo', 'lt', 'lu', 'mb', 'mc', 'me', 'mi', 'mn', 'mo', 'ms', 'mt', 'na', 'no', 'nu', 'or', 'pa', 'pc', 'pd', 'pe', 'pg', 'pi', 'pn', 'po', 'pr', 'pt', 'pu', 'pv', 'pz', 're', 'rg', 'ri', 'rm', 'rn', 'ro', 'sa', 'si', 'so', 'sp', 'sr', 'ss', 'su', 'sv', 'ta', 'te', 'tn', 'to', 'tp', 'tr', 'ts', 'tv', 'ud', 'va', 'vb', 'vc', 've', 'vi', 'vr', 'vt', 'vv', 'edu', 'gov', 'abruzzo', 'basilicata', 'calabria', 'campania', 'emilia-romagna', 'friuli-ve-giulia', 'lazio', 'liguria', 'lombardia', 'marche', 'molise', 'piemonte', 'puglia', 'sardegna', 'sicilia', 'toscana', 'trentino-a-adige', 'umbria', 'valle-aosta', 'veneto'],
|
||||||
|
'je' => ['co', 'net', 'org'],
|
||||||
|
'jo' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch'],
|
||||||
|
'jp' => ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
|
||||||
|
'ke' => ['co', 'ne', 'or', 'ac', 'go', 'me', 'mobi', 'info', 'sc', 'pro'],
|
||||||
|
'kg' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
|
||||||
|
'kr' => ['ac', 'co', 'go', 'hs', 'kg', 'mil', 'ms', 'ne', 'or', 'pe', 're', 'seoul', 'busan', 'daegu', 'incheon', 'gwangju', 'daejeon', 'ulsan', 'gyeonggi', 'gangwon', 'chungbuk', 'chungnam', 'jeonbuk', 'jeonnam', 'gyeongbuk', 'gyeongnam', 'jeju', 'sejong'],
|
||||||
|
'kz' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
|
||||||
|
'li' => [],
|
||||||
|
'lt' => ['gov'],
|
||||||
|
'lv' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'id', 'asn', 'conf'],
|
||||||
|
'ly' => ['com', 'net', 'org', 'gov', 'edu', 'sch', 'med', 'id'],
|
||||||
|
'ma' => ['co', 'net', 'org', 'gov', 'press', 'ac'],
|
||||||
|
'mk' => ['com', 'net', 'org', 'edu', 'gov', 'inf', 'name', 'pro'],
|
||||||
|
'mx' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
|
'my' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
|
||||||
|
'na' => ['com', 'net', 'org', 'alt', 'edu', 'gov', 'mil', 'pro'],
|
||||||
|
'net' => ['gb', 'hu', 'in', 'jp', 'se', 'uk', 'cn', 'nz'],
|
||||||
|
'ng' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name', 'gov'],
|
||||||
|
'ni' => ['ac', 'co', 'com', 'edu', 'gob', 'mil', 'net', 'nom', 'org'],
|
||||||
|
'nl' => ['bv', 'co'],
|
||||||
|
'no' => ['fhs', 'folkebibl', 'kommune', 'mil', 'stat', 'priv', 'vgs', 'dep', 'kommune'],
|
||||||
|
'nz' => ['co', 'net', 'org', 'ac', 'geek', 'gen', 'maori', 'school', 'parliament', 'govt', 'health', 'mil', 'crii', 'archie', 'geek', 'govt', 'health', 'maori', 'school'],
|
||||||
|
'om' => ['com', 'net', 'org', 'gov', 'edu', 'med', 'mil', 'sch'],
|
||||||
|
'org' => ['ae', 'us', 'lu'],
|
||||||
|
'pe' => ['com', 'net', 'org', 'gob', 'edu', 'mil', 'nom'],
|
||||||
|
'ph' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
|
'pk' => ['com', 'net', 'org', 'fam', 'biz', 'edu', 'gov', 'web'],
|
||||||
|
'pl' => ['com', 'net', 'org', 'aid', 'agro', 'atm', 'auto', 'biz', 'edu', 'gmina', 'gsm', 'info', 'mail', 'miasta', 'media', 'mil', 'ngo', 'nom', 'pc', 'powiat', 'priv', 'realestate', 'rel', 'sex', 'shop', 'sklep', 'sos', 'szkola', 'targi', 'tm', 'tourism', 'travel', 'turystyka', 'gov', 'ap', 'augov', 'bedzin', 'bialystok', 'bielawa', 'bierun', 'boleslawiec', 'bydgoszcz', 'bytom', 'cieszyn', 'czeladz', 'czest', 'dlugoleka', 'elblag', 'elk', 'glogow', 'gniezno', 'gorlice', 'gorzow', 'grodzisk', 'grudziadz', 'ilk', 'jaworzno', 'jelenia-gora', 'jgora', 'kalisz', 'kazimierz-dolny', 'karpacz', 'kartuzy', 'kaszuby', 'katowice', 'kepno', 'ketrzyn', 'klodzko', 'kobierzyce', 'kolobrzeg', 'konin', 'konskowola', 'krapkowice', 'krakow', 'krasnik', 'krasno', 'krosniewice', 'kutno', 'lapy', 'lebork', 'legnica', 'lezajsk', 'limanowa', 'lomza', 'lowicz', 'lubin', 'lukow', 'malbork', 'malopolska', 'mazowsze', 'mazury', 'mielec', 'milicz', 'mielno', 'mragowo', 'naklo', 'nowaruda', 'nysa', 'olawa', 'olecko', 'olkusz', 'olsztyn', 'opoczno', 'opole', 'ostrowiec', 'ostroleka', 'ostrowwlkp', 'pila', 'pisz', 'podhale', 'podlasie', 'polkowice', 'pomorze', 'pomorse', 'prochowice', 'pruszkow', 'przeworsk', 'pulawy', 'rabka', 'rawa-maz', 'rybnik', 'rzeszow', 'sanok', 'sejny', 'siedlce', 'slask', 'slupsk', 'sosnowiec', 'stalowa-wola', 'skoczow', 'starachowice', 'stargard', 'suwalki', 'swidnica', 'swiebodzin', 'swinoujscie', 'szczecin', 'szczytno', 'tarnobrzeg', 'tgory', 'turek', 'tychy', 'ustka', 'walbrzych', 'warmia', 'warszawa', 'waw', 'wegrow', 'wielun', 'wlocl', 'wloclawek', 'wodzislaw', 'wolomin', 'wroclaw', 'zachpomor', 'zagan', 'zarow', 'zgora', 'zgorzelec', 'plug'],
|
||||||
|
'pr' => ['ac', 'co', 'edu', 'gov', 'info', 'island', 'pro', 'net', 'org'],
|
||||||
|
'pt' => ['com', 'net', 'org', 'gov', 'edu', 'int', 'publ'],
|
||||||
|
'py' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'co'],
|
||||||
|
'qa' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name'],
|
||||||
|
'ro' => ['com', 'net', 'org', 'nom', 'rec', 'info', 'arts', 'com', 'firm', 'tm', 'www', 'store', 'nt', 'ngo', 'pro', 'tm', 'com', 'arts', 'rec', 'store', 'info', 'nom', 'nt', 'org', 'shop', 'firm', 'www', 'rest', 'travel', 'transport', 'tourism', 'press', 'media', 'medical', 'med', 'law', 'jobs', 'inst', 'individual', 'insinfo', 'guru', 'fit', 'engineering', 'expert', 'energy', 'economy', 'dot', 'dog', 'dev', 'design', 'dem', 'dental', 'craft', 'corp', 'consulting', 'construction', 'company', 'com', 'club', 'cloud', 'coach', 'city', 'cinema', 'church', 'chat', 'casino', 'cars', 'care', 'cards', 'broke', 'blog', 'bio', 'bid', 'band', 'auto', 'audio', 'attorney', 'apartments', 'app', 'art', 'archi', 'architects', 'arena', 'architects', 'associates', 'attorney', 'auction', 'auto', 'baby', 'band', 'bank', 'bar', 'bargains', 'beer', 'berlin', 'best', 'bet', 'bid', 'bike', 'bingo', 'bio', 'black', 'blog', 'blue', 'boats', 'bond', 'boo', 'book', 'boutique', 'build', 'builders', 'business', 'buzz', 'cab', 'cafe', 'call', 'cam', 'camp', 'capital', 'care', 'careers', 'cars', 'cash', 'casino', 'catering', 'center', 'ceo', 'ceramics', 'cfd', 'ch', 'chat', 'church', 'city', 'claims', 'cleaning', 'click', 'clinic', 'clothing', 'cloud', 'club', 'coach', 'codes', 'coffee', 'college', 'community', 'company', 'computer', 'condos', 'construction', 'consulting', 'contact', 'cooking', 'cool', 'country', 'courses', 'cpa', 'craft', 'credit', 'creditcard', 'cricket', 'cruise', 'cuisinella', 'cymru', 'dabur', 'dance', 'date', 'dating', 'deals', 'degree', 'delivery', 'democrat', 'dental', 'design', 'dev', 'diamonds', 'diet', 'digital', 'direct', 'directory', 'discount', 'dog', 'domains', 'doos', 'download', 'ec', 'edu', 'education', 'energy', 'engineering', 'enterprises', 'equipment', 'estate', 'events', 'exchange', 'expert', 'exposed', 'express', 'fail', 'faith', 'family', 'fan', 'farm', 'fashion', 'film', 'finance', 'financial', 'fish', 'fit', 'fitness', 'flights', 'florist', 'flowers', 'football', 'forex', 'forsale', 'foundation', 'fun', 'fund', 'furniture', 'futbol', 'fyi', 'gal', 'gallery', 'game', 'garden', 'gift', 'gifts', 'gives', 'glass', 'global', 'gold', 'golf', 'graphics', 'gratis', 'green', 'gripe', 'group', 'guru', 'health', 'healthcare', 'help', 'helsinki', 'here', 'hiphop', 'hiv', 'holdings', 'holiday', 'homes', 'horse', 'host', 'hosting', 'house', 'how', 'immo', 'immobilien', 'in', 'industries', 'info', 'ink', 'institute', 'insure', 'international', 'investments', 'irish', 'jewelry', 'kaufen', 'kids', 'kim', 'kitchen', 'kiwi', 'kred', 'land', 'law', 'lawyer', 'legal', 'lgbt', 'lifestyle', 'lighting', 'limited', 'limo', 'link', 'live', 'loan', 'loans', 'lol', 'london', 'love', 'ltd', 'ltda', 'luxury', 'maison', 'management', 'market', 'marketing', 'markets', 'media', 'memorial', 'men', 'menu', 'miami', 'mobi', 'moda', 'moe', 'mom', 'money', 'monster', 'mortgage', 'movie', 'nagoya', 'name', 'navy', 'net', 'network', 'news', 'ngo', 'ninja', 'nyc', 'observer', 'okinawa', 'one', 'ong', 'onl', 'online', 'ooo', 'org', 'organic', 'osaka', 'paris', 'partners', 'parts', 'party', 'photo', 'photography', 'photos', 'pics', 'pictures', 'pink', 'pizza', 'place', 'plumbing', 'plus', 'poker', 'porn', 'press', 'pro', 'productions', 'properties', 'property', 'pub', 'qpon', 'realtor', 'realty', 'recipes', 'red', 'rehab', 'reise', 'reisen', 'rent', 'rentals', 'repair', 'report', 'rest', 'restaurant', 'review', 'reviews', 'rich', 'rip', 'rocks', 'rodeo', 'run', 'saarland', 'sale', 'salon', 'sarl', 'save', 'saxo', 'school', 'schule', 'science', 'services', 'sex', 'sexy', 'sg', 'shop', 'shopping', 'show', 'singles', 'site', 'ski', 'soccer', 'social', 'software', 'solar', 'solutions', 'space', 'store', 'stream', 'studio', 'study', 'style', 'supplies', 'supply', 'support', 'surgery', 'systems', 'tax', 'taxi', 'team', 'tech', 'technology', 'tennis', 'thai', 'tips', 'tires', 'tirol', 'today', 'tokyo', 'tools', 'top', 'tour', 'tours', 'town', 'toys', 'trade', 'trading', 'training', 'travel', 'tube', 'university', 'uno', 'vacations', 'vegas', 'ventures', 'vet', 'viajes', 'video', 'villas', 'vin', 'vision', 'vlaanderen', 'vodka', 'vote', 'voting', 'voto', 'voyage', 'wales', 'watch', 'webcam', 'website', 'wedding', 'wien', 'wiki', 'win', 'wine', 'work', 'works', 'world', 'wtf', 'xxx', 'xyz', 'yoga', 'yokohama', 'zone'],
|
||||||
|
'ru' => ['ac', 'com', 'edu', 'int', 'net', 'org', 'pp', 'adygeya', 'altai', 'amur', 'arkhangelsk', 'astrakhan', 'bashkiria', 'belgorod', 'bir', 'bryansk', 'buryatia', 'cbg', 'chel', 'chelyabinsk', 'chita', 'chukotka', 'chuvashia', 'dagestan', 'dudinka', 'e-burg', 'grozny', 'irkutsk', 'ivanovo', 'izhevsk', 'jar', 'joshkar-ola', 'kalmykia', 'kaluga', 'kamchatka', 'karelia', 'kazan', 'kchr', 'kemerovo', 'khabarovsk', 'khakassia', 'khv', 'kirov', 'koenigsberg', 'komi', 'kostroma', 'krasnodar', 'krasnoyarsk', 'kuban', 'kurgan', 'kursk', 'lipetsk', 'magadan', 'mari', 'mari-el', 'marine', 'mil', 'mordovia', 'mosreg', 'msk', 'murmansk', 'nalchik', 'nnov', 'nov', 'novosibirsk', 'nsk', 'omsk', 'orenburg', 'oryol', 'palana', 'penza', 'perm', 'ptz', 'rnd', 'ryazan', 'sakhalin', 'samara', 'saratov', 'simbirsk', 'smolensk', 'spb', 'stavropol', 'stv', 'surgut', 'tambov', 'tatarstan', 'tom', 'tomsk', 'tsaritsyn', 'tsk', 'tula', 'tuva', 'tver', 'tyumen', 'udm', 'udmurtia', 'ulan-ude', 'vladikavkaz', 'vladimir', 'vladivostok', 'volgograd', 'vologda', 'voronezh', 'vrn', 'vyatka', 'yakutia', 'yamal', 'yaroslavl', 'yevrey'],
|
||||||
|
'sa' => ['com', 'net', 'org', 'gov', 'med', 'pub', 'edu', 'sch'],
|
||||||
|
'sb' => ['com', 'net', 'org', 'edu', 'gov'],
|
||||||
|
'sc' => ['com', 'net', 'org', 'gov', 'edu'],
|
||||||
|
'se' => ['a', 'ac', 'b', 'bd', 'brand', 'c', 'd', 'e', 'f', 'fh', 'fhsk', 'fhv', 'g', 'h', 'i', 'k', 'komforb', 'kommunal', 'komvux', 'kunskapsforb', 'l', 'lanbib', 'm', 'n', 'naturbruksgymn', 'o', 'org', 'p', 'parti', 'pp', 'press', 'r', 's', 't', 'tm', 'u', 'v', 'w', 'x', 'y', 'z'],
|
||||||
|
'sg' => ['com', 'net', 'org', 'gov', 'edu', 'per'],
|
||||||
|
'sh' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
|
||||||
|
'sk' => ['co', 'com', 'edu', 'gov', 'mil', 'net', 'org', 'nfo'],
|
||||||
|
'st' => ['co', 'com', 'consulado', 'edu', 'embaixada', 'gov', 'mil', 'net', 'org', 'principe', 'saotome', 'store'],
|
||||||
|
'su' => ['abkhazia', 'adygeya', 'ak', 'altai', 'amur', 'arkhangelsk', 'astrakhan', 'bashkiria', 'belgorod', 'bir', 'bryansk', 'buryatia', 'cbg', 'chel', 'chelyabinsk', 'chita', 'chukotka', 'chuvashia', 'dagestan', 'dudinka', 'e-burg', 'grozny', 'irkutsk', 'ivanovo', 'izhevsk', 'jar', 'joshkar-ola', 'kalmykia', 'kaluga', 'kamchatka', 'karelia', 'kazan', 'kchr', 'kemerovo', 'khabarovsk', 'khakassia', 'khv', 'kirov', 'koenigsberg', 'komi', 'kostroma', 'krasnodar', 'krasnoyarsk', 'kuban', 'kurgan', 'kursk', 'lipetsk', 'magadan', 'mari', 'mari-el', 'marine', 'mil', 'mordovia', 'mosreg', 'msk', 'murmansk', 'nalchik', 'nnov', 'nov', 'novosibirsk', 'nsk', 'omsk', 'orenburg', 'oryol', 'palana', 'penza', 'perm', 'ptz', 'rnd', 'ryazan', 'sakhalin', 'samara', 'saratov', 'simbirsk', 'smolensk', 'spb', 'stavropol', 'stv', 'surgut', 'tambov', 'tatarstan', 'tom', 'tomsk', 'tsaritsyn', 'tsk', 'tula', 'tuva', 'tver', 'tyumen', 'udm', 'udmurtia', 'ulan-ude', 'vladikavkaz', 'vladimir', 'vladivostok', 'volgograd', 'vologda', 'voronezh', 'vrn', 'vyatka', 'yakutia', 'yamal', 'yaroslavl', 'yevrey', 'com', 'net', 'org', 'gov', 'pp', 'edu'],
|
||||||
|
'sv' => ['com', 'edu', 'gob', 'org', 'red'],
|
||||||
|
'sy' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
|
||||||
|
'th' => ['ac', 'co', 'go', 'in', 'mi', 'net', 'or'],
|
||||||
|
'tj' => ['ac', 'biz', 'co', 'com', 'edu', 'gov', 'go', 'info', 'int', 'mil', 'name', 'net', 'nic', 'nom', 'org', 'pro', 'test', 'web'],
|
||||||
|
'tn' => ['agrinet', 'com', 'defense', 'edunet', 'ens', 'fin', 'gov', 'ind', 'info', 'intl', 'min', 'nat', 'net', 'org', 'perso', 'rnrt', 'rns', 'rnu', 'tourism', 'turen'],
|
||||||
|
'tr' => ['com', 'net', 'org', 'gov', 'biz', 'info', 'mil', 'edu', 'tv', 'bbs', 'k12', 'pol', 'bel', 'dr', 'gen', 'av', 'bbs', 'k12', 'name', 'tel', 'nc', 'web', 'tsk', 'bel', 'pol', 'edu'],
|
||||||
|
'tw' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'idv', 'game', 'ebiz', 'club', 'gnu'],
|
||||||
|
'ua' => ['com', 'net', 'org', 'edu', 'gov', 'in', 'at', 'cn', 'crimea', 'dn', 'dnepropetrovsk', 'donetsk', 'dp', 'if', 'ivano-frankivsk', 'kh', 'kharkov', 'kherson', 'khmelnitskiy', 'kiev', 'kirovograd', 'km', 'kr', 'ks', 'kv', 'lg', 'lt', 'lugansk', 'lutsk', 'lv', 'lviv', 'mk', 'mk.ua', 'mykolaiv', 'net', 'nikolaev', 'od', 'odessa', 'pl', 'poltava', 'rovno', 'rv', 'sebastopol', 'sm', 'sumy', 'te', 'ternopil', 'uz', 'uzhgorod', 'vinnica', 'vn', 'volyn', 'yalta', 'zaporizhzhe', 'zhitomir', 'zp', 'zt'],
|
||||||
|
'uk' => ['co', 'me', 'org', 'ltd', 'plc', 'net', 'sch', 'ac', 'gov', 'nhs', 'police', 'mod', 'nhs', 'parliament'],
|
||||||
|
'us' => ['ak', 'al', 'ar', 'as', 'az', 'ca', 'co', 'ct', 'dc', 'de', 'fl', 'ga', 'gu', 'hi', 'ia', 'id', 'il', 'in', 'ks', 'ky', 'la', 'ma', 'md', 'me', 'mi', 'mn', 'mo', 'ms', 'mt', 'nc', 'nd', 'ne', 'nh', 'nj', 'nm', 'nv', 'ny', 'oh', 'ok', 'or', 'pa', 'pr', 'ri', 'sc', 'sd', 'tn', 'tx', 'ut', 'vi', 'vt', 'va', 'wa', 'wi', 'wv', 'wy', 'dni', 'fed', 'isa', 'kids', 'nsn'],
|
||||||
|
'uy' => ['com', 'net', 'org', 'gub', 'mil', 'edu'],
|
||||||
|
've' => ['co', 'com', 'edu', 'gob', 'info', 'net', 'org', 'web'],
|
||||||
|
'vn' => ['com', 'net', 'org', 'edu', 'gov', 'int', 'ac', 'biz', 'info', 'name', 'pro', 'health'],
|
||||||
|
'yu' => ['ac', 'co', 'edu', 'gov', 'org'],
|
||||||
|
'za' => ['ac', 'alt', 'co', 'edu', 'gov', 'law', 'mil', 'net', 'ngo', 'nom', 'org', 'school', 'tm', 'web'],
|
||||||
];
|
];
|
||||||
|
|
||||||
private bool $subdomainRedirect;
|
private bool $subdomainRedirect;
|
||||||
@@ -36,30 +117,35 @@ final readonly class DomainManager {
|
|||||||
$this->authSubdomain = $authSubdomain;
|
$this->authSubdomain = $authSubdomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IE: "auth.example.com" or null if not using a separate subdomain
|
/** IE: "auth.example.com" or null if not using a separate subdomain.
|
||||||
* @return ?string Returns auth subdomain if configured, otherwise null */
|
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||||
public function getAuthSubdomain(): ?string {
|
public function getAuthSubdomain(): ?string
|
||||||
|
{
|
||||||
if ($this->authBase()) {
|
if ($this->authBase()) {
|
||||||
return $this->authSubdomain;
|
return $this->authSubdomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** check if given url is an acceptable url for redirection
|
/** check if given url is an acceptable url for redirection.
|
||||||
* @param string $url Where we are thinking of sending the user
|
* @param string $url Where we are thinking of sending the user
|
||||||
|
*
|
||||||
* @return bool Returns true if it is acceptable to send the user there */
|
* @return bool Returns true if it is acceptable to send the user there */
|
||||||
public function validReturn(string $url): bool {
|
public function validReturn(string $url): bool
|
||||||
|
{
|
||||||
/* ensure url is valid and, when using an auth subdomain,
|
/* ensure url is valid and, when using an auth subdomain,
|
||||||
* that the url host matches the base domain */
|
* that the url host matches the base domain */
|
||||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
if (!filter_var($url, \FILTER_VALIDATE_URL)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->authBase()) {
|
if ($this->authBase()) {
|
||||||
$host = parse_url($url, PHP_URL_HOST);
|
$host = parse_url($url, \PHP_URL_HOST);
|
||||||
if ($host === null) {
|
if (null === $host || false === $host || '' === $host) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* do not send the user to another domain */
|
/* do not send the user to another domain */
|
||||||
return $this->matchesAuth($host);
|
return $this->matchesAuth($host);
|
||||||
}
|
}
|
||||||
@@ -67,54 +153,64 @@ final readonly class DomainManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** check if host-base matches auth-base
|
/** check if host-base matches auth-base.
|
||||||
* @param string $host
|
|
||||||
* @return bool returns true if and only if host matches base domain of auth */
|
* @return bool returns true if and only if host matches base domain of auth */
|
||||||
public function matchesAuth(string $host): bool {
|
public function matchesAuth(string $host): bool
|
||||||
|
{
|
||||||
$hostBase = $this->baseDomain($host);
|
$hostBase = $this->baseDomain($host);
|
||||||
$authBase = $this->baseDomain($this->authSubdomain);
|
$authBase = $this->baseDomain($this->authSubdomain);
|
||||||
return $this->subdomainRedirect && $this->authSubdomain &&
|
|
||||||
$authBase && $authBase === $hostBase;
|
return $this->subdomainRedirect && $this->authSubdomain
|
||||||
|
&& $authBase && $authBase === $hostBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IE: "example.com" if central auth is something like "auth.example.com"
|
/** IE: "example.com" if central auth is something like "auth.example.com".
|
||||||
* @return string|null returns base domain if we are doing central auth */
|
* @return string|null returns base domain if we are doing central auth */
|
||||||
public function authBase(): ?string {
|
public function authBase(): ?string
|
||||||
|
{
|
||||||
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
|
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
|
||||||
return $this->baseDomain($this->authSubdomain);
|
return $this->baseDomain($this->authSubdomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** this lets us determine the base domain of the given ip, localhost, or domain
|
/** this lets us determine the base domain of the given ip, localhost, or domain
|
||||||
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
||||||
* things like "localhost" and "8.8.8.8" will return null
|
* things like "localhost" and "8.8.8.8" will return null.
|
||||||
|
*
|
||||||
* @param string $host ip, localhost, or domain with zero or more subdomains
|
* @param string $host ip, localhost, or domain with zero or more subdomains
|
||||||
|
*
|
||||||
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
|
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
|
||||||
private function baseDomain(string $host): ?string {
|
private function baseDomain(string $host): ?string
|
||||||
|
{
|
||||||
/* if host is an ip address (or localhost), leave it as is */
|
/* if host is an ip address (or localhost), leave it as is */
|
||||||
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
|
if (filter_var($host, \FILTER_VALIDATE_IP) || 'localhost' === $host) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$parts = explode('.', $host);
|
$parts = explode('.', strtolower($host));
|
||||||
$keep = $this->baseLength($parts);
|
$keep = $this->baseLength($parts);
|
||||||
$parts = array_slice($parts, -$keep);
|
$parts = \array_slice($parts, -$keep);
|
||||||
|
|
||||||
return implode('.', $parts);
|
return implode('.', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
|
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"].
|
||||||
* @param string[] $parts pieces of a domain split by "." dot
|
* @param string[] $parts pieces of a domain split by "." dot
|
||||||
|
*
|
||||||
* @return int typically 2 but sometimes 3 */
|
* @return int typically 2 but sometimes 3 */
|
||||||
private function baseLength(array $parts): int {
|
private function baseLength(array $parts): int
|
||||||
$length = count($parts);
|
{
|
||||||
|
$length = \count($parts);
|
||||||
$baseLength = min(2, $length);
|
$baseLength = min(2, $length);
|
||||||
/* check if host should retain 3 parts, due to TLD */
|
/* check if host should retain 3 parts, due to TLD */
|
||||||
if (count($parts) > 2 && isset(self::TLD[$parts[$length-1]]) &&
|
if (\count($parts) > 2 && isset(self::TLD[$parts[$length - 1]])
|
||||||
in_array($parts[$length-2], self::TLD[$parts[$length-1]], true)
|
&& \in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
|
||||||
) {
|
) {
|
||||||
$baseLength = min(3, $length);
|
$baseLength = min(3, $length);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $baseLength;
|
return $baseLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Data\Payload;
|
||||||
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
interface LoginInterface
|
||||||
|
{
|
||||||
|
/** @throws InvalidArgumentException */
|
||||||
|
public function checkToken(Payload $payload, Request $request): ?Response;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
@@ -18,7 +19,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Symfony\Component\Uid\Ulid;
|
use Symfony\Component\Uid\Ulid;
|
||||||
|
|
||||||
final readonly class LoginManager {
|
final readonly class LoginManager implements LoginInterface
|
||||||
|
{
|
||||||
use CookieNameTrait;
|
use CookieNameTrait;
|
||||||
use GetTotpTrait;
|
use GetTotpTrait;
|
||||||
use MakeNonceTrait;
|
use MakeNonceTrait;
|
||||||
@@ -29,22 +31,23 @@ final readonly class LoginManager {
|
|||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
CacheItemPoolInterface $sessionCache,
|
CacheItemPoolInterface $sessionCache,
|
||||||
private BackupCodeManager $backupCodeManager,
|
private BackupCodeInterface $backupCodeManager,
|
||||||
private DomainManager $domainManager,
|
private DomainInterface $domainManager,
|
||||||
) {
|
) {
|
||||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function checkToken(Payload $payload, Request $request): ?Response {
|
public function checkToken(Payload $payload, Request $request): ?Response
|
||||||
|
{
|
||||||
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||||
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
|
if (Scope::Ip === $payload->scope && !$this->config->ipTtl()) {
|
||||||
/* requested to grant ip access, but that is not enabled */
|
/* requested to grant ip access, but that is not enabled */
|
||||||
$payload->scope = Scope::Cookie;
|
$payload->scope = Scope::Cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->getTotp()->verify($payload->token, null, 10) ||
|
if ($this->getTotp()->verify($payload->token, null, 1)
|
||||||
$this->backupCodeManager->verifyAndConsume($payload->token)
|
|| $this->backupCodeManager->verifyAndConsume($payload->token)
|
||||||
) {
|
) {
|
||||||
/* token is correct (TOTP or Backup) */
|
/* token is correct (TOTP or Backup) */
|
||||||
|
|
||||||
@@ -53,23 +56,20 @@ final readonly class LoginManager {
|
|||||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||||
/* mark nonce as spent */
|
/* mark nonce as spent */
|
||||||
$nonceItem->set(false); /* invalid */
|
$nonceItem->set(false); /* invalid */
|
||||||
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */
|
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
|
||||||
$this->nonceCache->save($nonceItem);
|
$this->nonceCache->save($nonceItem);
|
||||||
|
|
||||||
/* token authentication successful, grant access and set response */
|
/* token authentication successful, grant access and set response */
|
||||||
$cleanId = $this->makeCacheKey($payload->id);
|
$cleanId = $this->makeCacheKey($payload->id);
|
||||||
|
|
||||||
/* if they just want this one page, return ok, to grant them access */
|
/* if they just want this one page, return ok, to grant them access */
|
||||||
$response = new Response("hi $cleanId", headers: [
|
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||||
'Content-Type' => 'text/plain',
|
|
||||||
'Remote-User' => $cleanId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($payload->scope !== Scope::None) {
|
if (Scope::None !== $payload->scope) {
|
||||||
/* grant access based on the requested scope */
|
/* grant access based on the requested scope */
|
||||||
if ($payload->scope === Scope::Cookie) {
|
if (Scope::Cookie === $payload->scope) {
|
||||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||||
} else if ($payload->scope === Scope::Ip) {
|
} elseif (Scope::Ip === $payload->scope) {
|
||||||
$this->setIp($cleanId, $request->getClientIp());
|
$this->setIp($cleanId, $request->getClientIp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ final readonly class LoginManager {
|
|||||||
$content = "hi $cleanId, please reload";
|
$content = "hi $cleanId, please reload";
|
||||||
}
|
}
|
||||||
|
|
||||||
$location = $request->query->has('return') &&
|
$location = $request->query->has('return')
|
||||||
$this->domainManager->validReturn($request->query->get('return')) ?
|
&& $this->domainManager->validReturn($request->query->get('return')) ?
|
||||||
"{$request->query->get('return')}" :
|
"{$request->query->get('return')}" :
|
||||||
"{$request->getPathInfo()}{$request->getQueryString()}";
|
"{$request->getPathInfo()}{$request->getQueryString()}";
|
||||||
|
|
||||||
@@ -97,38 +97,37 @@ final readonly class LoginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->debug("successful login for: $cleanId");
|
$this->logger->debug("successful login for: $cleanId");
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
private function setCookie(string $id, string $host): Cookie {
|
private function setCookie(string $id, string $host): Cookie
|
||||||
|
{
|
||||||
/* successful auth with token, store session and set the cookie */
|
/* successful auth with token, store session and set the cookie */
|
||||||
$ulid = new Ulid();
|
$ulid = new Ulid();
|
||||||
$sessionCookie = $this->sessionCache->getItem(
|
$sessionCookie = $this->sessionCache->getItem(
|
||||||
$this->makeCacheKey("cookie_$ulid")
|
$this->makeCacheKey("cookie_$ulid"),
|
||||||
);
|
);
|
||||||
if ($sessionCookie->isHit()) {
|
if ($sessionCookie->isHit()) {
|
||||||
/* it is supposed to be impossible to have collisions */
|
/* it is supposed to be impossible to have collisions */
|
||||||
$this->logger->error("aborting: ULID collision");
|
$this->logger->error('aborting: ULID collision');
|
||||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||||
}
|
}
|
||||||
$sessionCookie->set($id);
|
$sessionCookie->set($id);
|
||||||
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
||||||
$this->sessionCache->save($sessionCookie);
|
$this->sessionCache->save($sessionCookie);
|
||||||
|
|
||||||
/* when using subdomain-auth we have to use a different cookie name, as the
|
|
||||||
* "__Host-Http-" prefix we normally use does not allow domain to be set */
|
|
||||||
/* changes here must be reflected in InterceptListener::pruneInvalidCookie() */
|
|
||||||
return Cookie::create(
|
return Cookie::create(
|
||||||
name: $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
|
name: $this->sessionCookieName($this->domainManager),
|
||||||
value: $ulid->toString(),
|
value: $ulid->toString(),
|
||||||
expire: time() + $this->config->cookieTtl(),
|
expire: time() + $this->config->cookieTtl(),
|
||||||
path: '/',
|
path: '/',
|
||||||
/* if using central auth, only set the domain if the host matches */
|
domain: $this->sessionCookieDomain($this->domainManager, $host),
|
||||||
domain: $this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
|
|
||||||
secure: true,
|
secure: true,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: Cookie::SAMESITE_STRICT,
|
sameSite: Cookie::SAMESITE_STRICT,
|
||||||
@@ -136,7 +135,8 @@ final readonly class LoginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
private function setIp(string $id, string $ip): void {
|
private function setIp(string $id, string $ip): void
|
||||||
|
{
|
||||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches request paths against configured public path patterns.
|
||||||
|
*
|
||||||
|
* Patterns are provided as a comma-separated string in the format:
|
||||||
|
* /path/pattern, host.example.com/path/pattern, or a mix.
|
||||||
|
*
|
||||||
|
* Wildcards:
|
||||||
|
* - * matches any characters within a single path segment (not crossing /)
|
||||||
|
* - ** matches any characters including / (crosses path segments)
|
||||||
|
*
|
||||||
|
* Query strings are not part of the pattern — matching is against the
|
||||||
|
* path only.
|
||||||
|
*/
|
||||||
|
final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
||||||
|
{
|
||||||
|
/** @var list<array{host: ?string, regex: string}> */
|
||||||
|
private array $patterns;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire('%app.public_paths%')] string $publicPaths,
|
||||||
|
) {
|
||||||
|
$this->patterns = $this->parse($publicPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmpty(): bool
|
||||||
|
{
|
||||||
|
return [] === $this->patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function matches(string $host, string $path): bool
|
||||||
|
{
|
||||||
|
if ([] === $this->patterns) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower($host);
|
||||||
|
|
||||||
|
foreach ($this->patterns as $entry) {
|
||||||
|
if (null !== $entry['host'] && $entry['host'] !== $host) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 === preg_match($entry['regex'], $path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the comma-separated PUBLIC_PATHS string into pattern entries.
|
||||||
|
*
|
||||||
|
* @return list<array{host: ?string, regex: string}>
|
||||||
|
*/
|
||||||
|
private function parse(string $publicPaths): array
|
||||||
|
{
|
||||||
|
if ('' === trim($publicPaths)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = [];
|
||||||
|
|
||||||
|
foreach (explode(',', $publicPaths) as $raw) {
|
||||||
|
$entry = trim($raw);
|
||||||
|
if ('' === $entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a host prefix (anything before the first /)
|
||||||
|
$host = null;
|
||||||
|
$path = $entry;
|
||||||
|
|
||||||
|
if (preg_match('/^([a-z0-9.-]+)(\/.*)$/i', $entry, $m)) {
|
||||||
|
$host = strtolower($m[1]);
|
||||||
|
$path = $m[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path starts with /
|
||||||
|
if (!str_starts_with($path, '/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns[] = [
|
||||||
|
'host' => $host,
|
||||||
|
'regex' => $this->compilePattern($path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a wildcard path pattern into a regex string.
|
||||||
|
*
|
||||||
|
* Star becomes a character class matching one or more non-slash chars.
|
||||||
|
* Double-star at end of pattern matches zero or more of any char.
|
||||||
|
* Double-star followed by slash matches zero or more path segments.
|
||||||
|
* Other characters are escaped as literal regex.
|
||||||
|
*/
|
||||||
|
private function compilePattern(string $pattern): string
|
||||||
|
{
|
||||||
|
$regex = '';
|
||||||
|
$length = \strlen($pattern);
|
||||||
|
$i = 0;
|
||||||
|
|
||||||
|
while ($i < $length) {
|
||||||
|
// Check for ** (must be at current position)
|
||||||
|
if ($i + 1 < $length && '*' === $pattern[$i] && '*' === $pattern[$i + 1]) {
|
||||||
|
$i += 2;
|
||||||
|
if ($i >= $length) {
|
||||||
|
// ** at end of pattern: zero or more chars including /
|
||||||
|
$regex .= '.*';
|
||||||
|
} elseif ('/' === $pattern[$i]) {
|
||||||
|
// /**/ in middle: zero or more intermediate segments
|
||||||
|
$regex .= '(?:.*/)?';
|
||||||
|
++$i; // skip the / after **
|
||||||
|
} else {
|
||||||
|
// ** not followed by / or end, treat as .*
|
||||||
|
$regex .= '.*';
|
||||||
|
}
|
||||||
|
} elseif ('*' === $pattern[$i]) {
|
||||||
|
$regex .= '[^/]+';
|
||||||
|
++$i;
|
||||||
|
} else {
|
||||||
|
$regex .= preg_quote($pattern[$i], '#');
|
||||||
|
++$i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#^'.$regex.'$#';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches request paths against configured public path patterns.
|
||||||
|
*
|
||||||
|
* Patterns support simple wildcards:
|
||||||
|
* - `*` matches any characters within a single path segment (not crossing `/`)
|
||||||
|
* - `**` matches any characters including `/` (crosses path segments)
|
||||||
|
*
|
||||||
|
* Patterns may optionally include a host prefix (e.g. `example.com/public/**`).
|
||||||
|
* When no host prefix is given, the pattern matches on any host.
|
||||||
|
*/
|
||||||
|
interface PublicPathMatcherInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Returns true if the given host and path match any configured public pattern.
|
||||||
|
*
|
||||||
|
* @param string $host The request host (e.g. "code.example.com")
|
||||||
|
* @param string $path The request path (e.g. "/public/repo/issues")
|
||||||
|
*/
|
||||||
|
public function matches(string $host, string $path): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if no public paths are configured (feature is disabled).
|
||||||
|
*/
|
||||||
|
public function isEmpty(): bool;
|
||||||
|
}
|
||||||
@@ -1,22 +1,48 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Trait;
|
namespace App\Trait;
|
||||||
|
|
||||||
trait CookieNameTrait {
|
use App\Service\DomainInterface;
|
||||||
|
|
||||||
|
trait CookieNameTrait
|
||||||
|
{
|
||||||
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||||
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
||||||
private const string HEADER_NAME = 'X-Preauth';
|
private const string HEADER_NAME = 'X-Preauth';
|
||||||
|
|
||||||
final protected function cookieName(): string {
|
final protected function cookieName(): string
|
||||||
|
{
|
||||||
return static::COOKIE_NAME;
|
return static::COOKIE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
final protected function authCookieName(): string {
|
final protected function authCookieName(): string
|
||||||
|
{
|
||||||
return static::AUTH_COOKIE_NAME;
|
return static::AUTH_COOKIE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
final protected function headerName(): string {
|
final protected function headerName(): string
|
||||||
|
{
|
||||||
return static::HEADER_NAME;
|
return static::HEADER_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the appropriate cookie name based on whether central auth is active.
|
||||||
|
* Uses the __Host- prefix for single-domain mode (no Domain attribute),
|
||||||
|
* and a non-prefixed name for central auth (Domain attribute required).
|
||||||
|
*/
|
||||||
|
final protected function sessionCookieName(DomainInterface $domainManager): string
|
||||||
|
{
|
||||||
|
return $domainManager->authBase() ? $this->authCookieName() : $this->cookieName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cookie domain for central auth mode, or null for single-domain.
|
||||||
|
* The domain is only set when the host matches the auth base domain.
|
||||||
|
*/
|
||||||
|
final protected function sessionCookieDomain(DomainInterface $domainManager, string $host): ?string
|
||||||
|
{
|
||||||
|
return $domainManager->matchesAuth($host) ? $domainManager->authBase() : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Trait;
|
namespace App\Trait;
|
||||||
@@ -6,24 +7,29 @@ namespace App\Trait;
|
|||||||
use App\ConfigBag;
|
use App\ConfigBag;
|
||||||
use OTPHP\Factory;
|
use OTPHP\Factory;
|
||||||
use OTPHP\TOTPInterface;
|
use OTPHP\TOTPInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Symfony\Contracts\Service\Attribute\Required;
|
use Symfony\Contracts\Service\Attribute\Required;
|
||||||
|
|
||||||
trait GetTotpTrait {
|
trait GetTotpTrait
|
||||||
|
{
|
||||||
protected readonly ConfigBag $config;
|
protected readonly ConfigBag $config;
|
||||||
|
|
||||||
#[Required]
|
#[Required]
|
||||||
public function setConfig(ConfigBag $config): void {
|
public function setConfig(ConfigBag $config): void
|
||||||
|
{
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getTotp(): TOTPInterface {
|
protected function getTotp(): TOTPInterface
|
||||||
|
{
|
||||||
$otp = Factory::loadFromProvisioningUri(
|
$otp = Factory::loadFromProvisioningUri(
|
||||||
$this->config->totpUri(), $this->config->clock()
|
$this->config->totpUri(),
|
||||||
|
$this->config->clock(),
|
||||||
);
|
);
|
||||||
if ($otp instanceof TOTPInterface) {
|
if ($otp instanceof TOTPInterface) {
|
||||||
return $otp;
|
return $otp;
|
||||||
}
|
}
|
||||||
throw new HttpException(500, 'Internal Server Exception');
|
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Trait;
|
namespace App\Trait;
|
||||||
@@ -6,11 +7,13 @@ namespace App\Trait;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Contracts\Service\Attribute\Required;
|
use Symfony\Contracts\Service\Attribute\Required;
|
||||||
|
|
||||||
trait HasLoggerTrait {
|
trait HasLoggerTrait
|
||||||
|
{
|
||||||
protected readonly LoggerInterface $logger;
|
protected readonly LoggerInterface $logger;
|
||||||
|
|
||||||
#[Required]
|
#[Required]
|
||||||
public function setLogger(LoggerInterface $logger): void {
|
public function setLogger(LoggerInterface $logger): void
|
||||||
|
{
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Trait;
|
namespace App\Trait;
|
||||||
@@ -10,7 +11,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Symfony\Contracts\Service\Attribute\Required;
|
use Symfony\Contracts\Service\Attribute\Required;
|
||||||
|
|
||||||
trait MakeNonceTrait {
|
trait MakeNonceTrait
|
||||||
|
{
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
@@ -21,26 +23,26 @@ trait MakeNonceTrait {
|
|||||||
protected readonly CacheItemPoolInterface $nonceCache;
|
protected readonly CacheItemPoolInterface $nonceCache;
|
||||||
|
|
||||||
#[Required]
|
#[Required]
|
||||||
public function setNonceCache(CacheItemPoolInterface $nonceCache): void {
|
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
|
||||||
|
{
|
||||||
$this->nonceCache = $nonceCache;
|
$this->nonceCache = $nonceCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException|Exception */
|
/** @throws InvalidArgumentException|Exception */
|
||||||
protected function makeNonce(int $retries = 3): string {
|
protected function makeNonce(int $retries = 3): string
|
||||||
|
{
|
||||||
/* convert raw binary into base64url */
|
/* convert raw binary into base64url */
|
||||||
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
||||||
static::NONCE_LENGTH
|
static::NONCE_LENGTH,
|
||||||
)), '+/', '-_'), '=');
|
)), '+/', '-_'), '=');
|
||||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
|
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
|
||||||
|
|
||||||
if ($nonceItem->isHit()) {
|
if ($nonceItem->isHit()) {
|
||||||
if ($retries < 1) {
|
if ($retries < 1) {
|
||||||
$this->logger->error("aborting: multiple nonce collisions");
|
$this->logger->error('aborting: multiple nonce collisions');
|
||||||
throw new HttpException(
|
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||||
Response::HTTP_INTERNAL_SERVER_ERROR,
|
|
||||||
'Internal Server Error'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* managed to have a collision, try again */
|
/* managed to have a collision, try again */
|
||||||
return $this->makeNonce($retries - 1);
|
return $this->makeNonce($retries - 1);
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,7 @@ trait MakeNonceTrait {
|
|||||||
$nonceItem->expiresAfter(static::NONCE_TTL);
|
$nonceItem->expiresAfter(static::NONCE_TTL);
|
||||||
$this->logger->debug("added nonce: $nonce");
|
$this->logger->debug("added nonce: $nonce");
|
||||||
$this->nonceCache->save($nonceItem);
|
$this->nonceCache->save($nonceItem);
|
||||||
|
|
||||||
return $nonce;
|
return $nonce;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,53 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Trait;
|
namespace App\Trait;
|
||||||
|
|
||||||
trait StringTrait {
|
use App\AppConstants;
|
||||||
|
use App\ConfigBag;
|
||||||
|
use App\Enum\RemoteUserMode;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
trait StringTrait
|
||||||
|
{
|
||||||
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
|
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
|
||||||
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
|
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
|
||||||
|
|
||||||
public function makeCacheKey(string $name): string {
|
public function makeCacheKey(string $name): string
|
||||||
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
|
{
|
||||||
|
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the plain-text success response body and headers for an
|
||||||
|
* authenticated request. The body is a simple greeting that includes
|
||||||
|
* the session id. The Remote-User header is set (or omitted) based on
|
||||||
|
* the configured remote-user mode.
|
||||||
|
*/
|
||||||
|
public function authSuccessResponse(string $id, ConfigBag $config): Response
|
||||||
|
{
|
||||||
|
$headers = ['Content-Type' => 'text/plain'];
|
||||||
|
|
||||||
|
$headerValue = $this->resolveRemoteUser($id, $config);
|
||||||
|
if (null !== $headerValue) {
|
||||||
|
$headers['Remote-User'] = $headerValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("hi $id", headers: $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Remote-User header value based on the configured mode.
|
||||||
|
* Returns null when the header should not be sent.
|
||||||
|
*/
|
||||||
|
private function resolveRemoteUser(string $id, ConfigBag $config): ?string
|
||||||
|
{
|
||||||
|
return match ($config->remoteUserMode()) {
|
||||||
|
RemoteUserMode::Session => $id,
|
||||||
|
RemoteUserMode::Static => $config->remoteUserStatic(),
|
||||||
|
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
|
||||||
|
RemoteUserMode::None => null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-8
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
@@ -11,14 +12,17 @@ use Psr\Cache\CacheItemPoolInterface;
|
|||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Psr\Clock\ClockInterface;
|
use Psr\Clock\ClockInterface;
|
||||||
|
|
||||||
final readonly class Utilities {
|
final readonly class Utilities
|
||||||
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ClockInterface $clock,
|
private ClockInterface $clock,
|
||||||
private CacheItemPoolInterface $appPool,
|
private CacheItemPoolInterface $appPool,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function loadTotp(): string {
|
public function loadTotp(): string
|
||||||
|
{
|
||||||
/* user forgot to set their TOTP_URI in the environment */
|
/* user forgot to set their TOTP_URI in the environment */
|
||||||
if ($this->appPool->hasItem('totp')) {
|
if ($this->appPool->hasItem('totp')) {
|
||||||
$totp = $this->appPool->getItem('totp')->get();
|
$totp = $this->appPool->getItem('totp')->get();
|
||||||
@@ -27,11 +31,13 @@ final readonly class Utilities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->showTotp($totp);
|
$this->showTotp($totp);
|
||||||
|
|
||||||
return $totp;
|
return $totp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
private function makeTotp(): string {
|
private function makeTotp(): string
|
||||||
|
{
|
||||||
/* we have not stored a totp into the app cache yet */
|
/* we have not stored a totp into the app cache yet */
|
||||||
$totpObj = TOTP::generate($this->clock);
|
$totpObj = TOTP::generate($this->clock);
|
||||||
$totpObj->setLabel('Preauth-TOTP');
|
$totpObj->setLabel('Preauth-TOTP');
|
||||||
@@ -41,21 +47,26 @@ final readonly class Utilities {
|
|||||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
/* 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 */
|
* we want this to keep forever, so a few hundred years should do it */
|
||||||
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d', '2999-12-31'
|
'Y-m-d',
|
||||||
|
AppConstants::FAR_FUTURE_DATE,
|
||||||
));
|
));
|
||||||
$this->appPool->save($totpItem);
|
$this->appPool->save($totpItem);
|
||||||
|
|
||||||
return $totp;
|
return $totp;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function showTotp(string $totp): void {
|
private function showTotp(string $totp): void
|
||||||
|
{
|
||||||
$writer = new Writer(new PlainTextRenderer());
|
$writer = new Writer(new PlainTextRenderer());
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
'php://stderr', <<<RAW
|
'php://stderr',
|
||||||
|
<<<RAW
|
||||||
{$writer->writeString($totp)}
|
{$writer->writeString($totp)}
|
||||||
$totp
|
$totp
|
||||||
loading TOTP, because the env is not set, please copy above into TOTP_URI
|
loading TOTP, because the env is not set, please copy above into TOTP_URI
|
||||||
|
|
||||||
RAW, FILE_APPEND
|
RAW,
|
||||||
|
\FILE_APPEND,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,34 @@
|
|||||||
{
|
{
|
||||||
|
"friendsofphp/php-cs-fixer": {
|
||||||
|
"version": "3.95",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.39",
|
||||||
|
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".php-cs-fixer.dist.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phpstan/phpstan": {
|
||||||
|
"version": "2.2.15"
|
||||||
|
},
|
||||||
|
"phpunit/phpunit": {
|
||||||
|
"version": "13.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "11.1",
|
||||||
|
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env.test",
|
||||||
|
"phpunit.dist.xml",
|
||||||
|
"tests/bootstrap.php",
|
||||||
|
"bin/phpunit"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/console": {
|
"symfony/console": {
|
||||||
"version": "7.4",
|
"version": "7.4",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ form.addEventListener('submit', (event) => {
|
|||||||
fetch(window.location.href, {
|
fetch(window.location.href, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'X-Preauth': data },
|
headers: { 'X-Preauth': data },
|
||||||
|
// never serve this request from, or store it in, the HTTP cache
|
||||||
|
cache: 'no-store',
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
{% if env.debug > 2 -%}
|
{% if env.debug > 2 -%}
|
||||||
console.log(response);
|
console.log(response);
|
||||||
@@ -28,7 +30,8 @@ form.addEventListener('submit', (event) => {
|
|||||||
{% if env.debug > 2 -%}
|
{% if env.debug > 2 -%}
|
||||||
console.log('got redirect response');
|
console.log('got redirect response');
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
window.location.href = response.headers.get('Location');
|
// replace() keeps the login page out of history and the back-forward cache
|
||||||
|
window.location.replace(response.headers.get('Location'));
|
||||||
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
|
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
|
||||||
{# got json, update the page #}
|
{# got json, update the page #}
|
||||||
{% if env.debug > 2 -%}
|
{% if env.debug > 2 -%}
|
||||||
@@ -53,9 +56,7 @@ form.addEventListener('submit', (event) => {
|
|||||||
console.log('got html response');
|
console.log('got html response');
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
response.text().then((html) => {
|
response.text().then((html) => {
|
||||||
document.open();
|
document.documentElement.innerHTML = html;
|
||||||
document.write(html);
|
|
||||||
document.close();
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.log('failed to get html from response');
|
console.log('failed to get html from response');
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{{ env.title }}</title>
|
<title>{{ env.title }}</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
{{- include('_style.html.twig') -}}
|
{{- include('_style.html.twig') -}}
|
||||||
</head>
|
</head>
|
||||||
<body id="preauth-body">
|
<body id="preauth-body">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{# The nonce field serves dual purpose: replay prevention AND CSRF protection.
|
||||||
|
An attacker cannot forge a POST request without a valid nonce, which is
|
||||||
|
generated server-side per page load and tied to the user's session. #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ env.title }}</h1>
|
<h1>{{ env.title }}</h1>
|
||||||
<p id="preauth-message">{{ message|default }}</p>
|
<p id="preauth-message">{{ message|default }}</p>
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use App\Data\Payload;
|
||||||
|
use App\Enum\Scope;
|
||||||
|
use OTPHP\TOTP;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end functional tests exercising the full HTTP kernel: the request
|
||||||
|
* travels through RejectListener -> LoginListener -> AllowListener ->
|
||||||
|
* AcceptListener -> InterceptListener and the services they orchestrate.
|
||||||
|
*/
|
||||||
|
final class AuthenticationFlowTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||||
|
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||||
|
|
||||||
|
protected static function createClient(array $options = [], array $server = []): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = parent::createClient($options, $server);
|
||||||
|
// The app stores nonces in the (in-memory) nonceCache pool. In
|
||||||
|
// production APCu keeps them across requests, but KernelBrowser
|
||||||
|
// reboots the kernel between requests by default which would lose
|
||||||
|
// them. Disable the reboot so the nonce issued on the login-page
|
||||||
|
// request survives to the login-submission request.
|
||||||
|
$client->disableReboot();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validTotpCode(): string
|
||||||
|
{
|
||||||
|
// the app uses the real system clock, so generate the code for now()
|
||||||
|
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
|
||||||
|
private function encodePayload(array $data): string
|
||||||
|
{
|
||||||
|
$json = json_encode($data, \JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginPayload(
|
||||||
|
string $id = 'testuser',
|
||||||
|
?string $token = null,
|
||||||
|
string $nonce = 'test-nonce-abc',
|
||||||
|
bool $json = true,
|
||||||
|
): string {
|
||||||
|
return $this->encodePayload([
|
||||||
|
'id' => $id,
|
||||||
|
'token' => $token ?? $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => $json,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── unauthenticated access ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_unauthenticated_request_shows_login_page(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/');
|
||||||
|
|
||||||
|
// login page is served with 401 (Unauthorized) to signal the proxy
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
self::assertSelectorExists('form#preauth-form');
|
||||||
|
self::assertSelectorExists('input[name="nonce"]');
|
||||||
|
self::assertSelectorExists('input[name="username"]');
|
||||||
|
self::assertSelectorExists('input[name="totp"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_page_contains_generated_nonce(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
|
||||||
|
$nonceInput = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
self::assertNotEmpty($nonceInput);
|
||||||
|
// base64url charset
|
||||||
|
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_form_does_not_use_post_method_without_auth_subdomain(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
|
||||||
|
$form = $crawler->filter('form#preauth-form');
|
||||||
|
// without central auth, the form should NOT have method="post"
|
||||||
|
$method = $form->attr('method');
|
||||||
|
self::assertNull($method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── successful TOTP login ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_successful_totp_login_via_header_sets_cookie_and_redirects(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// first, grab a valid nonce from the login page
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
self::assertNotEmpty($nonce);
|
||||||
|
|
||||||
|
// now submit a valid TOTP via the X-Preauth header
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(303, $response->getStatusCode()); // SEE_OTHER
|
||||||
|
self::assertTrue($response->headers->has('Location'));
|
||||||
|
// a session cookie should be set
|
||||||
|
$cookies = $response->headers->getCookies();
|
||||||
|
$hasPreauthCookie = false;
|
||||||
|
foreach ($cookies as $cookie) {
|
||||||
|
if (str_contains($cookie->getName(), 'Preauth')) {
|
||||||
|
$hasPreauthCookie = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_successful_login_returns_json_when_json_requested(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'bob',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(303, $response->getStatusCode());
|
||||||
|
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
self::assertSame('Login successful', $body['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_successful_login_returns_html_when_json_false(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'carol',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => false,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(303, $response->getStatusCode());
|
||||||
|
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_cookie_access_after_login(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// login
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'dave',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// grab the cookie value from the login response
|
||||||
|
$loginResponse = $client->getResponse();
|
||||||
|
$cookieValue = null;
|
||||||
|
foreach ($loginResponse->headers->getCookies() as $cookie) {
|
||||||
|
if (str_contains($cookie->getName(), 'Preauth')) {
|
||||||
|
$cookieValue = $cookie->getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertNotNull($cookieValue);
|
||||||
|
|
||||||
|
// the cookie was set with secure=true, so the CookieJar will only
|
||||||
|
// send it over HTTPS; the KernelBrowser automatically updates the
|
||||||
|
// CookieJar from the login response, so the next request over HTTPS
|
||||||
|
// will include it
|
||||||
|
$client->request('GET', 'https://localhost/dashboard');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertSame('dave', $response->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scope_none_returns_plain_text_without_redirect(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'eve',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'scope' => 'none',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
|
||||||
|
self::assertSame('eve', $response->headers->get('Remote-User'));
|
||||||
|
// no redirect for scope=none
|
||||||
|
self::assertFalse($response->headers->has('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── failed login ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_failed_login_returns_unauthorized_json_with_error(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => '000000', // wrong code
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(401, $response->getStatusCode());
|
||||||
|
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
self::assertArrayHasKey('message', $body);
|
||||||
|
self::assertArrayHasKey('nonce', $body);
|
||||||
|
// a fresh nonce should be returned for the next attempt
|
||||||
|
self::assertNotEmpty($body['nonce']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_login_returns_html_when_json_false(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => 'wrong-code',
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => false,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(401, $response->getStatusCode());
|
||||||
|
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||||
|
self::assertSelectorExists('form#preauth-form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_login_with_spent_nonce_is_rejected(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
// first: successful login consumes the nonce
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
// the successful login set a session cookie; clear it so the next
|
||||||
|
// request is not auto-authenticated by AcceptListener before the
|
||||||
|
// login attempt is even evaluated
|
||||||
|
$client->getCookieJar()->clear();
|
||||||
|
|
||||||
|
// reuse the same nonce — should fail even with a valid token
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_login_with_invalid_nonce_is_rejected(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// skip fetching a real nonce; use one that was never stored
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => 'never-issued-nonce',
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── invalid payload ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_invalid_header_payload_returns_unauthorized(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => '!!!not-valid-base64!!!',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// decode fails -> null payload -> failure path -> 401
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_payload_with_missing_fields_returns_unauthorized(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// payload missing token
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice', 'nonce' => 'some-nonce',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── invalid cookie ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_invalid_cookie_is_cleared_and_login_page_shown(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// the cookie must be set via the CookieJar so that the HttpFoundation
|
||||||
|
// Request actually populates its cookies bag (HTTP_COOKIE alone is
|
||||||
|
// not parsed by Request::create)
|
||||||
|
$client->getCookieJar()->set(
|
||||||
|
new \Symfony\Component\BrowserKit\Cookie(
|
||||||
|
self::COOKIE_NAME,
|
||||||
|
'invalid-ulid-value',
|
||||||
|
null,
|
||||||
|
'/',
|
||||||
|
'localhost',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
'Strict',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->request('GET', 'https://localhost/');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
// not authenticated -> login page with 401
|
||||||
|
self::assertSame(401, $response->getStatusCode());
|
||||||
|
// the stale cookie should be cleared
|
||||||
|
$cleared = false;
|
||||||
|
foreach ($response->headers->getCookies() as $cookie) {
|
||||||
|
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
|
||||||
|
$cleared = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── backup code authentication ───────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_backup_code_authentication_works(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$container = $client->getContainer();
|
||||||
|
|
||||||
|
// generate a backup code via the BackupCodeManager
|
||||||
|
$manager = $container->get(\App\Service\BackupCodeInterface::class);
|
||||||
|
$codes = $manager->generate(1);
|
||||||
|
self::assertCount(1, $codes);
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'frank',
|
||||||
|
'token' => $codes[0],
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_consumed_backup_code_cannot_be_reused(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$container = $client->getContainer();
|
||||||
|
|
||||||
|
$manager = $container->get(\App\Service\BackupCodeInterface::class);
|
||||||
|
$codes = $manager->generate(1);
|
||||||
|
$code = $codes[0];
|
||||||
|
|
||||||
|
// first use
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'frank', 'token' => $code, 'nonce' => $nonce, 'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
// the successful login set a session cookie; clear it so the next
|
||||||
|
// request reaches the login page instead of being auto-authenticated
|
||||||
|
$client->getCookieJar()->clear();
|
||||||
|
|
||||||
|
// second use with a fresh nonce
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce2 = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'frank', 'token' => $code, 'nonce' => $nonce2, 'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── return URL handling ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_successful_login_with_valid_return_url(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/?return=https://example.com/app');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/?return=https://example.com/app', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce, 'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(303, $response->getStatusCode());
|
||||||
|
self::assertSame('https://example.com/app', $response->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_successful_login_with_invalid_return_falls_back_to_path(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/?return=not-a-url');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/?return=not-a-url', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce, 'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(303, $response->getStatusCode());
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
// should fall back to the request path (with query string),
|
||||||
|
// not redirect to the invalid return URL as an absolute URL
|
||||||
|
self::assertStringStartsWith('/', $location);
|
||||||
|
// the invalid return URL is not used as the redirect target
|
||||||
|
self::assertStringNotContainsString('//not-a-url', $location);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use OTPHP\TOTP;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end checks that the login flow carries strict anti-caching headers
|
||||||
|
* on everything the browser can see, while 2xx grants ("already
|
||||||
|
* authenticated" / public access) — which the reverse proxy consumes in its
|
||||||
|
* forward_auth check and never forwards to the browser — are left untouched.
|
||||||
|
*/
|
||||||
|
final class CacheControlFlowTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
|
protected static function createClient(array $options = [], array $server = []): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = parent::createClient($options, $server);
|
||||||
|
$client->disableReboot();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validTotpCode(): string
|
||||||
|
{
|
||||||
|
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodePayload(array $data): string
|
||||||
|
{
|
||||||
|
$json = json_encode($data, \JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertNotCacheable(Response $response): void
|
||||||
|
{
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('proxy-revalidate'));
|
||||||
|
self::assertSame('0', $response->headers->getCacheControlDirective('max-age'));
|
||||||
|
self::assertSame('0', $response->headers->getCacheControlDirective('s-maxage'));
|
||||||
|
self::assertSame('no-cache', $response->headers->get('Pragma'));
|
||||||
|
self::assertSame('0', $response->headers->get('Expires'));
|
||||||
|
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
|
||||||
|
self::assertSame('*', $response->headers->get('Vary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCacheable(Response $response): void
|
||||||
|
{
|
||||||
|
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertNull($response->headers->get('Pragma'));
|
||||||
|
self::assertNull($response->headers->get('Surrogate-Control'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── login flow: nothing may be cached ────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_login_page_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(401, $response->getStatusCode());
|
||||||
|
$this->assertNotCacheable($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_page_fetch_bypasses_http_cache(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/');
|
||||||
|
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
// the inline login script must opt out of the HTTP cache and must
|
||||||
|
// not leave the login page in history / the back-forward cache
|
||||||
|
self::assertStringContainsString("cache: 'no-store'", $content);
|
||||||
|
self::assertStringContainsString('window.location.replace(', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_login_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => '000000', 'nonce' => $nonce, 'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(401, $response->getStatusCode());
|
||||||
|
$this->assertNotCacheable($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_successful_login_redirect_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => $this->validTotpCode(), 'nonce' => $nonce, 'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(303, $response->getStatusCode());
|
||||||
|
$this->assertNotCacheable($response);
|
||||||
|
// the redirect target must still be present
|
||||||
|
self::assertTrue($response->headers->has('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_page_on_another_host_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
// the listener applies to every main response, not only the primary
|
||||||
|
// host; subdomain redirection itself is covered by InterceptListener
|
||||||
|
// unit tests
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', 'https://other.example.com/');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(401, $response->getStatusCode());
|
||||||
|
$this->assertNotCacheable($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limited_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// the login limiter is raised for tests, so exercise the public
|
||||||
|
// limiter instead (test config: PUBLIC_BURST_COUNT=3)
|
||||||
|
for ($i = 0; $i < 4; ++$i) {
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(429, $response->getStatusCode());
|
||||||
|
$this->assertNotCacheable($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 2xx grants: must stay untouched ──────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_authenticated_access_response_is_not_modified_by_anti_caching_headers(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// login and keep the cookie
|
||||||
|
$crawler = $client->request('GET', '/');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
$client->request('GET', '/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'dave', 'token' => $this->validTotpCode(), 'nonce' => $nonce, 'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
// subsequent authenticated requests return a 200 "grant" response
|
||||||
|
$client->request('GET', 'https://localhost/dashboard');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertSame('dave', $response->headers->get('Remote-User'));
|
||||||
|
// 2xx responses are consumed by forward_auth and never reach the
|
||||||
|
// browser — they must not carry the login-flow anti-caching headers
|
||||||
|
$this->assertCacheable($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_public_access_response_is_not_modified_by_anti_caching_headers(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$this->assertCacheable($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use OTPHP\TOTP;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end functional tests for the public rate-limited access feature.
|
||||||
|
*
|
||||||
|
* The test environment (phpunit.dist.xml) configures:
|
||||||
|
* PUBLIC_PATHS=/public/**
|
||||||
|
* PUBLIC_BURST_COUNT=3, PUBLIC_BURST_TIME=60
|
||||||
|
* PUBLIC_UPPER_COUNT=10000 (effectively unlimited for test purposes)
|
||||||
|
*
|
||||||
|
* @covers \App\Listener\PublicAccessListener
|
||||||
|
* @covers \App\Service\PublicPathMatcher
|
||||||
|
*/
|
||||||
|
final class PublicAccessFlowTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
|
protected static function createClient(array $options = [], array $server = []): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = parent::createClient($options, $server);
|
||||||
|
$client->disableReboot();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validTotpCode(): string
|
||||||
|
{
|
||||||
|
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodePayload(array $data): string
|
||||||
|
{
|
||||||
|
$json = json_encode($data, \JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── public path accessible without auth ───────────────────────────── */
|
||||||
|
|
||||||
|
public function test_public_path_accessible_without_authentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/some-repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
// No Remote-User header for public access
|
||||||
|
self::assertFalse($response->headers->has('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_public_path_with_querystring_accessible(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/repo?tab=issues&page=2');
|
||||||
|
|
||||||
|
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_deep_public_path_accessible(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/org/repo/issues/42');
|
||||||
|
|
||||||
|
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── non-public path requires auth ─────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_non_public_path_shows_login_page(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/private/settings');
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
self::assertSelectorExists('form#preauth-form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_root_path_shows_login_page(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_exact_public_path_without_slash_not_matched(): void
|
||||||
|
{
|
||||||
|
// /public/** does NOT match /public (no trailing content)
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public');
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── rate limiting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_rate_limit_enforced_after_burst_exceeded(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
|
||||||
|
for ($i = 0; $i < 3; ++$i) {
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
self::assertSame(
|
||||||
|
200,
|
||||||
|
$client->getResponse()->getStatusCode(),
|
||||||
|
"Request $i should have been allowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th request should be rate limited
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(429, $response->getStatusCode());
|
||||||
|
self::assertTrue($response->headers->has('Retry-After'));
|
||||||
|
$retryAfter = (int) $response->headers->get('Retry-After');
|
||||||
|
self::assertGreaterThan(0, $retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── authenticated user bypasses public rate limiter ───────────────── */
|
||||||
|
|
||||||
|
public function test_authenticated_user_bypasses_public_rate_limit(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// First, exhaust the public rate limiter
|
||||||
|
for ($i = 0; $i < 4; ++$i) {
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
}
|
||||||
|
// Confirm rate limit is in effect
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
self::assertSame(429, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
// Now log in — the cookie should let us bypass public rate limiting
|
||||||
|
$client->getCookieJar()->clear();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/private');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/private', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
// Now visit a public path while authenticated — should get 200
|
||||||
|
// (AcceptListener runs before PublicAccessListener, so the public
|
||||||
|
// rate limiter is never consulted)
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
// Authenticated users get Remote-User header
|
||||||
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 200 response has correct content type ─────────────────────────── */
|
||||||
|
|
||||||
|
public function test_public_access_response_is_plain_text(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 429 response renders error template ───────────────────────────── */
|
||||||
|
|
||||||
|
public function test_rate_limited_response_renders_error_template(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// Exhaust rate limit
|
||||||
|
for ($i = 0; $i < 4; ++$i) {
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(429, $response->getStatusCode());
|
||||||
|
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||||
|
$content = $response->getContent();
|
||||||
|
// The error template renders either teapot or too-many-requests content
|
||||||
|
// Default test env has TEAPOT=true
|
||||||
|
self::assertNotEmpty($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── security headers still applied to public responses ────────────── */
|
||||||
|
|
||||||
|
public function test_security_headers_on_public_access(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
// SecurityHeadersListener runs on all main-request responses
|
||||||
|
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
|
||||||
|
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\RateLimiter\LimiterInterface;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimit;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers for constructing the collaborators that the kernel listeners
|
||||||
|
* depend on, without booting the full Symfony container.
|
||||||
|
*/
|
||||||
|
trait ListenerTestHelper
|
||||||
|
{
|
||||||
|
use TotpTestHelper;
|
||||||
|
|
||||||
|
/** Build a Twig Environment pointed at the project's real templates. */
|
||||||
|
private function makeTwig(): Environment
|
||||||
|
{
|
||||||
|
$loader = new FilesystemLoader(\dirname(__DIR__, 2).'/templates');
|
||||||
|
$twig = new Environment($loader, ['strict_variables' => true]);
|
||||||
|
// the templates reference a global `env` object; supply one with the
|
||||||
|
// keys used by base/login/error/_script/_style
|
||||||
|
$twig->addGlobal('env', (object) [
|
||||||
|
'title' => 'Pre-Authentication System',
|
||||||
|
'bg_color' => '#029386',
|
||||||
|
'fg_color' => '#ffffff',
|
||||||
|
'error_color' => '#ffb16d',
|
||||||
|
'id_name' => 'Session ID',
|
||||||
|
'token_name' => 'Authentication Token',
|
||||||
|
'submit_name' => 'Submit',
|
||||||
|
'error_message' => 'Unsuccessful login attempt',
|
||||||
|
'teapot' => true,
|
||||||
|
'teapot_title' => "I'm a teapot",
|
||||||
|
'teapot_message' => 'I refuse to brew coffee',
|
||||||
|
'too_many_title' => 'Too many requests',
|
||||||
|
'too_many_message' => 'Try again later',
|
||||||
|
'debug' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $twig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A RateLimiterFactoryInterface whose created limiter returns a RateLimit
|
||||||
|
* with the given remaining tokens.
|
||||||
|
*/
|
||||||
|
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
|
||||||
|
{
|
||||||
|
$limiter = $this->makeLimiter($remainingTokens);
|
||||||
|
|
||||||
|
return new class($limiter) implements RateLimiterFactoryInterface {
|
||||||
|
public function __construct(private LimiterInterface $limiter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(?string $key = null): LimiterInterface
|
||||||
|
{
|
||||||
|
return $this->limiter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeLimiter(int $remainingTokens): LimiterInterface
|
||||||
|
{
|
||||||
|
$rateLimit = new RateLimit(
|
||||||
|
$remainingTokens,
|
||||||
|
new DateTimeImmutable('+10 seconds'),
|
||||||
|
$remainingTokens > 0,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new class($rateLimit) implements LimiterInterface {
|
||||||
|
public function __construct(private RateLimit $rateLimit)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||||
|
{
|
||||||
|
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consume(int $tokens = 1): RateLimit
|
||||||
|
{
|
||||||
|
return $this->rateLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A factory whose limiter tracks how many consume(1) calls were made and
|
||||||
|
* reports the limit as reached only after $threshold failures.
|
||||||
|
*/
|
||||||
|
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
|
||||||
|
{
|
||||||
|
$limiter = new class($threshold) implements LimiterInterface {
|
||||||
|
private int $consumed = 0;
|
||||||
|
|
||||||
|
public function __construct(private int $threshold)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||||
|
{
|
||||||
|
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consume(int $tokens = 1): RateLimit
|
||||||
|
{
|
||||||
|
$this->consumed += $tokens;
|
||||||
|
$remaining = max(0, $this->threshold - $this->consumed);
|
||||||
|
|
||||||
|
return new RateLimit(
|
||||||
|
$remaining,
|
||||||
|
new DateTimeImmutable('+10 seconds'),
|
||||||
|
$remaining > 0,
|
||||||
|
$this->threshold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(): void
|
||||||
|
{
|
||||||
|
$this->consumed = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new class($limiter) implements RateLimiterFactoryInterface {
|
||||||
|
public function __construct(private LimiterInterface $limiter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(?string $key = null): LimiterInterface
|
||||||
|
{
|
||||||
|
return $this->limiter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
|
use App\ConfigBag;
|
||||||
|
use App\Utilities;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use OTPHP\TOTP;
|
||||||
|
use Psr\Cache\CacheItemInterface;
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Psr\Clock\ClockInterface as PsrClockInterface;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a deterministic TOTP fixture plus a frozen clock and ready-made
|
||||||
|
* ConfigBag / cache-pool helpers for tests that exercise TOTP-dependent code.
|
||||||
|
*/
|
||||||
|
trait TotpTestHelper
|
||||||
|
{
|
||||||
|
/** well-known Base32 test secret (JBSWY3DPEHPK3PXP) */
|
||||||
|
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
|
/** Frozen timestamp used for deterministic TOTP codes. */
|
||||||
|
protected const string FROZEN_TIME = '2025-06-15 12:00:00';
|
||||||
|
|
||||||
|
/** Frozen clock that always returns the same instant. */
|
||||||
|
private function frozenClock(): PsrClockInterface
|
||||||
|
{
|
||||||
|
$time = self::FROZEN_TIME;
|
||||||
|
|
||||||
|
return new class($time) implements PsrClockInterface {
|
||||||
|
public function __construct(private string $time)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable($this->time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provisioning URI built from the well-known secret + frozen clock. */
|
||||||
|
private function totpUri(): string
|
||||||
|
{
|
||||||
|
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
|
||||||
|
$totp->setLabel('Test-TOTP');
|
||||||
|
|
||||||
|
return $totp->getProvisioningUri();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The TOTP code that is valid at the frozen timestamp. */
|
||||||
|
private function validTotpCode(): string
|
||||||
|
{
|
||||||
|
return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */
|
||||||
|
private function emptyPool(): CacheItemPoolInterface
|
||||||
|
{
|
||||||
|
return new ArrayAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ConfigBag wired with the deterministic TOTP and frozen clock.
|
||||||
|
* Extra params override the sensible defaults.
|
||||||
|
*/
|
||||||
|
private function makeConfig(
|
||||||
|
?int $cookieTtl = 3600,
|
||||||
|
?int $ipTtl = 0,
|
||||||
|
bool $teapot = true,
|
||||||
|
string $errorMessage = 'Error',
|
||||||
|
string $teapotTitle = 'Teapot',
|
||||||
|
string $tooManyTitle = 'Too Many',
|
||||||
|
string $remoteUserMode = 'session',
|
||||||
|
string $remoteUserStatic = 'authenticated',
|
||||||
|
string $remoteUserMap = '',
|
||||||
|
): ConfigBag {
|
||||||
|
$clock = $this->frozenClock();
|
||||||
|
$utilities = $this->createUtilities($clock);
|
||||||
|
|
||||||
|
return new ConfigBag(
|
||||||
|
$utilities,
|
||||||
|
$clock,
|
||||||
|
$cookieTtl,
|
||||||
|
$this->totpUri(),
|
||||||
|
$ipTtl,
|
||||||
|
$teapot,
|
||||||
|
$errorMessage,
|
||||||
|
$teapotTitle,
|
||||||
|
$tooManyTitle,
|
||||||
|
$remoteUserMode,
|
||||||
|
$remoteUserStatic,
|
||||||
|
$remoteUserMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Utilities stub that never triggers TOTP generation when
|
||||||
|
* a non-empty totpUri is supplied to ConfigBag.
|
||||||
|
*/
|
||||||
|
private function createUtilities(?PsrClockInterface $clock = null): Utilities
|
||||||
|
{
|
||||||
|
$clock ??= $this->frozenClock();
|
||||||
|
$cache = $this->createStub(CacheItemPoolInterface::class);
|
||||||
|
$cache->method('hasItem')->willReturn(false);
|
||||||
|
$item = $this->createStub(CacheItemInterface::class);
|
||||||
|
$item->method('isHit')->willReturn(false);
|
||||||
|
$cache->method('getItem')->willReturn($item);
|
||||||
|
|
||||||
|
return new Utilities($clock, $cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Kernel as AppKernel;
|
||||||
|
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kernel used by the functional test suite.
|
||||||
|
*
|
||||||
|
* In production the nonce cache is backed by APCu, which naturally persists
|
||||||
|
* across PHP requests. In the test environment the nonce cache is an
|
||||||
|
* in-memory ArrayAdapter; Symfony's ServicesResetter clears it between
|
||||||
|
* requests (even with KernelBrowser::disableReboot()), which would discard
|
||||||
|
* the nonce issued on the login-page request before the login-submission
|
||||||
|
* request can verify it.
|
||||||
|
*
|
||||||
|
* This kernel removes the kernel.reset tag from the nonceCache (and
|
||||||
|
* rateLimitCache) pools so their in-memory state survives across requests
|
||||||
|
* within a single test, mirroring the persistence behaviour of APCu.
|
||||||
|
*/
|
||||||
|
class TestKernel extends AppKernel
|
||||||
|
{
|
||||||
|
protected function build(ContainerBuilder $container): void
|
||||||
|
{
|
||||||
|
parent::build($container);
|
||||||
|
|
||||||
|
$container->addCompilerPass(new class implements CompilerPassInterface {
|
||||||
|
public function process(ContainerBuilder $container): void
|
||||||
|
{
|
||||||
|
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
|
||||||
|
if ($container->hasDefinition($poolId)) {
|
||||||
|
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit;
|
||||||
|
|
||||||
|
use App\Clock;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ClockTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_now_returns_date_time_immutable(): void
|
||||||
|
{
|
||||||
|
$clock = new Clock();
|
||||||
|
$before = new DateTimeImmutable();
|
||||||
|
$now = $clock->now();
|
||||||
|
$after = new DateTimeImmutable();
|
||||||
|
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $now);
|
||||||
|
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
|
||||||
|
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Command;
|
||||||
|
|
||||||
|
use App\Command\GenerateBackupCodesCommand;
|
||||||
|
use App\PersistCache;
|
||||||
|
use App\Service\BackupCodeInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
final class GenerateBackupCodesCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
/** PersistCache is final, so construct a real one backed by ArrayAdapters. */
|
||||||
|
private function makePersistCache(): PersistCache
|
||||||
|
{
|
||||||
|
return new PersistCache(new ArrayAdapter(), new ArrayAdapter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stub BackupCodeInterface that returns the given codes from generate(). */
|
||||||
|
private function makeManagerStub(array $generatedCodes): BackupCodeInterface
|
||||||
|
{
|
||||||
|
$manager = $this->createStub(BackupCodeInterface::class);
|
||||||
|
$manager->method('generate')->willReturn($generatedCodes);
|
||||||
|
|
||||||
|
return $manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_generate_default_count_outputs_codes(): void
|
||||||
|
{
|
||||||
|
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
|
||||||
|
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
|
||||||
|
$command = new GenerateBackupCodesCommand(
|
||||||
|
$this->makeManagerStub($codes),
|
||||||
|
$this->makePersistCache(),
|
||||||
|
);
|
||||||
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
|
$tester = new CommandTester($command);
|
||||||
|
$exit = $tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(0, $exit);
|
||||||
|
$output = $tester->getDisplay();
|
||||||
|
foreach ($codes as $code) {
|
||||||
|
self::assertStringContainsString($code, $output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_generate_specific_count_passes_count_to_manager(): void
|
||||||
|
{
|
||||||
|
$manager = $this->createMock(BackupCodeInterface::class);
|
||||||
|
$manager->expects(self::once())
|
||||||
|
->method('generate')
|
||||||
|
->with(self::identicalTo(5))
|
||||||
|
->willReturn(['c1', 'c2', 'c3', 'c4', 'c5']);
|
||||||
|
|
||||||
|
$command = new GenerateBackupCodesCommand($manager, $this->makePersistCache());
|
||||||
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
|
$tester = new CommandTester($command);
|
||||||
|
$exit = $tester->execute(['count' => 5]);
|
||||||
|
|
||||||
|
self::assertSame(0, $exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_default_count_argument_is_ten(): void
|
||||||
|
{
|
||||||
|
// the configured default for the count argument should be 10
|
||||||
|
$manager = $this->createMock(BackupCodeInterface::class);
|
||||||
|
$manager->expects(self::once())
|
||||||
|
->method('generate')
|
||||||
|
->with(self::identicalTo(10))
|
||||||
|
->willReturn(array_fill(0, 10, 'code'));
|
||||||
|
|
||||||
|
$command = new GenerateBackupCodesCommand($manager, $this->makePersistCache());
|
||||||
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
|
$tester = new CommandTester($command);
|
||||||
|
$tester->execute([]);
|
||||||
|
|
||||||
|
// assertion is in the mock expectation above
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_boots_and_persists_cache(): void
|
||||||
|
{
|
||||||
|
// PersistCache is final and can't be mocked, but we can verify the
|
||||||
|
// command runs end-to-end with a real instance; boot()/persist()
|
||||||
|
// are invoked implicitly. A successful exit confirms both were called
|
||||||
|
// without throwing.
|
||||||
|
$command = new GenerateBackupCodesCommand(
|
||||||
|
$this->makeManagerStub(['code1']),
|
||||||
|
$this->makePersistCache(),
|
||||||
|
);
|
||||||
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
|
$tester = new CommandTester($command);
|
||||||
|
$exit = $tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(0, $exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_zero_codes_throws_exception(): void
|
||||||
|
{
|
||||||
|
// count must be a positive integer — zero is rejected
|
||||||
|
$command = new GenerateBackupCodesCommand(
|
||||||
|
$this->makeManagerStub([]),
|
||||||
|
$this->makePersistCache(),
|
||||||
|
);
|
||||||
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
|
$tester = new CommandTester($command);
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$tester->execute(['count' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_command_name_and_description_are_configured(): void
|
||||||
|
{
|
||||||
|
$command = new GenerateBackupCodesCommand(
|
||||||
|
$this->makeManagerStub(['dummy']),
|
||||||
|
$this->makePersistCache(),
|
||||||
|
);
|
||||||
|
// configuring via the Application runs the protected configure()
|
||||||
|
$app = new \Symfony\Component\Console\Application();
|
||||||
|
$app->addCommand($command);
|
||||||
|
self::assertSame('app:generate-backup-codes', $command->getName());
|
||||||
|
// the source uses a non-breaking hyphen (U+2011) in "single‑use",
|
||||||
|
// so assert against the substring to avoid encoding fragility
|
||||||
|
self::assertStringContainsString('backup codes', $command->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit;
|
||||||
|
|
||||||
|
use App\Enum\RemoteUserMode;
|
||||||
|
use App\Tests\Support\TotpTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ConfigBagRemoteUserTest extends TestCase
|
||||||
|
{
|
||||||
|
use TotpTestHelper;
|
||||||
|
|
||||||
|
public function test_default_remote_user_mode_is_session(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig();
|
||||||
|
|
||||||
|
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_static_mode(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
||||||
|
|
||||||
|
self::assertSame(RemoteUserMode::Static, $config->remoteUserMode());
|
||||||
|
self::assertSame('authenticated', $config->remoteUserStatic());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mapped_mode(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
|
||||||
|
|
||||||
|
self::assertSame(RemoteUserMode::Mapped, $config->remoteUserMode());
|
||||||
|
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_none_mode(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(remoteUserMode: 'none');
|
||||||
|
|
||||||
|
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_mode_falls_back_to_session(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
|
||||||
|
|
||||||
|
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_empty_map_returns_empty_array(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
|
||||||
|
|
||||||
|
self::assertSame([], $config->remoteUserMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_map_parses_with_whitespace(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(
|
||||||
|
remoteUserMode: 'mapped',
|
||||||
|
remoteUserMap: ' alice : admin , bob : user ',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_map_ignores_invalid_entries(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(
|
||||||
|
remoteUserMode: 'mapped',
|
||||||
|
remoteUserMap: 'alice:admin,noColon,bob:user',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_map_preserves_colons_in_value(): void
|
||||||
|
{
|
||||||
|
$config = $this->makeConfig(
|
||||||
|
remoteUserMode: 'mapped',
|
||||||
|
remoteUserMap: 'alice:admin:extra',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['alice' => 'admin:extra'], $config->remoteUserMap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit;
|
||||||
|
|
||||||
|
use App\ConfigBag;
|
||||||
|
use App\Utilities;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Cache\CacheItemInterface;
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Psr\Clock\ClockInterface;
|
||||||
|
|
||||||
|
final class ConfigBagTest extends TestCase
|
||||||
|
{
|
||||||
|
private function createUtilities(?string $totp = null): Utilities
|
||||||
|
{
|
||||||
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
|
$cache = $this->createStub(CacheItemPoolInterface::class);
|
||||||
|
|
||||||
|
if (null !== $totp) {
|
||||||
|
$item = $this->createStub(CacheItemInterface::class);
|
||||||
|
$item->method('isHit')->willReturn(true);
|
||||||
|
$item->method('get')->willReturn($totp);
|
||||||
|
$cache->method('hasItem')->willReturn(true);
|
||||||
|
$cache->method('getItem')->willReturn($item);
|
||||||
|
} else {
|
||||||
|
$cache->method('hasItem')->willReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Utilities($clock, $cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_getters_with_explicit_values(): void
|
||||||
|
{
|
||||||
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
|
$utilities = $this->createUtilities();
|
||||||
|
|
||||||
|
$config = new ConfigBag(
|
||||||
|
$utilities,
|
||||||
|
$clock,
|
||||||
|
3600,
|
||||||
|
'otpauth://totp/test',
|
||||||
|
1800,
|
||||||
|
true,
|
||||||
|
'Error!',
|
||||||
|
'Teapot!',
|
||||||
|
'Too Many!',
|
||||||
|
'session',
|
||||||
|
'authenticated',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame($clock, $config->clock());
|
||||||
|
self::assertSame(3600, $config->cookieTtl());
|
||||||
|
self::assertSame('otpauth://totp/test', $config->totpUri());
|
||||||
|
self::assertSame(1800, $config->ipTtl());
|
||||||
|
self::assertTrue($config->teapot());
|
||||||
|
self::assertSame('Error!', $config->errorMessage());
|
||||||
|
self::assertSame('Teapot!', $config->teapotTitle());
|
||||||
|
self::assertSame('Too Many!', $config->tooManyTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_totp_uri_falls_back_to_utilities_when_empty(): void
|
||||||
|
{
|
||||||
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
|
$utilities = $this->createUtilities('fallback-totp');
|
||||||
|
|
||||||
|
$config = new ConfigBag(
|
||||||
|
$utilities,
|
||||||
|
$clock,
|
||||||
|
3600,
|
||||||
|
'',
|
||||||
|
1800,
|
||||||
|
false,
|
||||||
|
'Error',
|
||||||
|
'Teapot',
|
||||||
|
'Too Many',
|
||||||
|
'session',
|
||||||
|
'authenticated',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('fallback-totp', $config->totpUri());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ip_ttl_falls_back_to_null_when_zero(): void
|
||||||
|
{
|
||||||
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
|
$utilities = $this->createUtilities();
|
||||||
|
|
||||||
|
$config = new ConfigBag(
|
||||||
|
$utilities,
|
||||||
|
$clock,
|
||||||
|
3600,
|
||||||
|
'otpauth://totp/test',
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
'Error',
|
||||||
|
'Teapot',
|
||||||
|
'Too Many',
|
||||||
|
'session',
|
||||||
|
'authenticated',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($config->ipTtl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ip_ttl_falls_back_to_null_when_null(): void
|
||||||
|
{
|
||||||
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
|
$utilities = $this->createUtilities();
|
||||||
|
|
||||||
|
$config = new ConfigBag(
|
||||||
|
$utilities,
|
||||||
|
$clock,
|
||||||
|
3600,
|
||||||
|
'otpauth://totp/test',
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
'Error',
|
||||||
|
'Teapot',
|
||||||
|
'Too Many',
|
||||||
|
'session',
|
||||||
|
'authenticated',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($config->ipTtl());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Data;
|
||||||
|
|
||||||
|
use App\Data\Payload;
|
||||||
|
use App\Enum\Scope;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\InputBag;
|
||||||
|
|
||||||
|
final class PayloadTest extends TestCase
|
||||||
|
{
|
||||||
|
private static function b64u(string $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_valid_base64_url(): void
|
||||||
|
{
|
||||||
|
$data = json_encode([
|
||||||
|
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
|
||||||
|
'json' => true, 'scope' => 'cookie',
|
||||||
|
]);
|
||||||
|
$payload = Payload::decode(self::b64u($data));
|
||||||
|
|
||||||
|
self::assertInstanceOf(Payload::class, $payload);
|
||||||
|
self::assertSame('testuser', $payload->id);
|
||||||
|
self::assertSame('123456', $payload->token);
|
||||||
|
self::assertSame('abc123', $payload->nonce);
|
||||||
|
self::assertTrue($payload->json);
|
||||||
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_invalid_base64_url_returns_null(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_non_object_json_returns_null(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Payload::decode(self::b64u('"just a string"')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_invalid_json_returns_null(): void
|
||||||
|
{
|
||||||
|
// valid base64url but invalid JSON
|
||||||
|
self::assertNull(Payload::decode(self::b64u('{invalid json')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_json_array_returns_null(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_json_null_returns_null(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Payload::decode(self::b64u('null')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_json_boolean_returns_null(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Payload::decode(self::b64u('true')));
|
||||||
|
self::assertNull(Payload::decode(self::b64u('false')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_json_number_returns_null(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Payload::decode(self::b64u('42')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decode_empty_string_returns_null(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Payload::decode(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_load_with_valid_input_bag(): void
|
||||||
|
{
|
||||||
|
$input = new InputBag([
|
||||||
|
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
|
||||||
|
]);
|
||||||
|
$payload = Payload::load($input);
|
||||||
|
|
||||||
|
self::assertInstanceOf(Payload::class, $payload);
|
||||||
|
self::assertSame('alice', $payload->id);
|
||||||
|
self::assertSame('nonce123', $payload->nonce);
|
||||||
|
self::assertSame('654321', $payload->token);
|
||||||
|
self::assertFalse($payload->json);
|
||||||
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_load_missing_username_returns_null(): void
|
||||||
|
{
|
||||||
|
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
|
||||||
|
self::assertNull(Payload::load($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_load_missing_nonce_returns_null(): void
|
||||||
|
{
|
||||||
|
$input = new InputBag(['username' => 'u', 'totp' => 't']);
|
||||||
|
self::assertNull(Payload::load($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_load_missing_totp_returns_null(): void
|
||||||
|
{
|
||||||
|
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
|
||||||
|
self::assertNull(Payload::load($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_load_with_all_fields_present_but_empty_returns_null(): void
|
||||||
|
{
|
||||||
|
// has() returns true for all, but create() rejects empty values
|
||||||
|
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
|
||||||
|
self::assertNull(Payload::load($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_valid_data(): void
|
||||||
|
{
|
||||||
|
$data = (object) [
|
||||||
|
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||||
|
'json' => false, 'scope' => 'ip',
|
||||||
|
];
|
||||||
|
$payload = Payload::create($data);
|
||||||
|
|
||||||
|
self::assertInstanceOf(Payload::class, $payload);
|
||||||
|
self::assertSame('user1', $payload->id);
|
||||||
|
self::assertSame('tok1', $payload->token);
|
||||||
|
self::assertSame('non1', $payload->nonce);
|
||||||
|
self::assertFalse($payload->json);
|
||||||
|
self::assertSame(Scope::Ip, $payload->scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_default_scope(): void
|
||||||
|
{
|
||||||
|
$data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||||
|
$payload = Payload::create($data);
|
||||||
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_invalid_scope_falls_back_to_cookie(): void
|
||||||
|
{
|
||||||
|
$data = (object) [
|
||||||
|
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||||
|
'scope' => 'admin',
|
||||||
|
];
|
||||||
|
$payload = Payload::create($data);
|
||||||
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_missing_json_defaults_to_true(): void
|
||||||
|
{
|
||||||
|
$data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||||
|
$payload = Payload::create($data);
|
||||||
|
self::assertTrue($payload->json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_none_scope_sets_json_false(): void
|
||||||
|
{
|
||||||
|
$data = (object) [
|
||||||
|
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||||
|
'json' => true, 'scope' => 'none',
|
||||||
|
];
|
||||||
|
$payload = Payload::create($data);
|
||||||
|
self::assertSame(Scope::None, $payload->scope);
|
||||||
|
self::assertFalse($payload->json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_empty_id_returns_null(): void
|
||||||
|
{
|
||||||
|
$data = (object) ['id' => '', 'token' => 't', 'nonce' => 'n'];
|
||||||
|
self::assertNull(Payload::create($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_whitespace_id_returns_null(): void
|
||||||
|
{
|
||||||
|
$data = (object) ['id' => ' ', 'token' => 't', 'nonce' => 'n'];
|
||||||
|
self::assertNull(Payload::create($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_empty_token_returns_null(): void
|
||||||
|
{
|
||||||
|
$data = (object) ['id' => 'u', 'token' => '', 'nonce' => 'n'];
|
||||||
|
self::assertNull(Payload::create($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_with_empty_nonce_returns_null(): void
|
||||||
|
{
|
||||||
|
$data = (object) ['id' => 'u', 'token' => 't', 'nonce' => ''];
|
||||||
|
self::assertNull(Payload::create($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_trims_and_truncates_fields(): void
|
||||||
|
{
|
||||||
|
$long = str_repeat('a', 200);
|
||||||
|
$data = (object) [
|
||||||
|
'id' => ' '.$long.' ',
|
||||||
|
'token' => ' '.$long.' ',
|
||||||
|
'nonce' => ' '.$long.' ',
|
||||||
|
];
|
||||||
|
$payload = Payload::create($data);
|
||||||
|
$expected = mb_substr($long, 0, 128);
|
||||||
|
self::assertSame($expected, $payload->id);
|
||||||
|
self::assertSame($expected, $payload->token);
|
||||||
|
self::assertSame($expected, $payload->nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_to_string(): void
|
||||||
|
{
|
||||||
|
$payload = new Payload();
|
||||||
|
$payload->id = 'u';
|
||||||
|
$payload->token = 't';
|
||||||
|
$payload->nonce = 'n';
|
||||||
|
$payload->json = true;
|
||||||
|
$payload->scope = Scope::Cookie;
|
||||||
|
|
||||||
|
$decoded = json_decode($payload->toString(), true);
|
||||||
|
self::assertSame('u', $decoded['id']);
|
||||||
|
self::assertSame('t', $decoded['token']);
|
||||||
|
self::assertSame('n', $decoded['nonce']);
|
||||||
|
self::assertTrue($decoded['json']);
|
||||||
|
self::assertSame('cookie', $decoded['scope']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Enum;
|
||||||
|
|
||||||
|
use App\Enum\Scope;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ScopeTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_cases(): void
|
||||||
|
{
|
||||||
|
self::assertSame('cookie', Scope::Cookie->value);
|
||||||
|
self::assertSame('ip', Scope::Ip->value);
|
||||||
|
self::assertSame('none', Scope::None->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_try_from_valid(): void
|
||||||
|
{
|
||||||
|
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
|
||||||
|
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
|
||||||
|
self::assertSame(Scope::None, Scope::tryFrom('none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_try_from_invalid(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Scope::tryFrom('invalid'));
|
||||||
|
self::assertNull(Scope::tryFrom(''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\ConfigBag;
|
||||||
|
use App\Listener\AcceptListener;
|
||||||
|
use App\Service\DomainManager;
|
||||||
|
use App\Tests\Support\TotpTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
final class AcceptListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
use TotpTestHelper;
|
||||||
|
|
||||||
|
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||||
|
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
||||||
|
|
||||||
|
private function makeListener(
|
||||||
|
ArrayAdapter $pool,
|
||||||
|
DomainManager $domainManager,
|
||||||
|
?ConfigBag $config = null,
|
||||||
|
): AcceptListener {
|
||||||
|
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
|
return $listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Request $request): RequestEvent
|
||||||
|
{
|
||||||
|
return new RequestEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request,
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── valid cookie session ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_valid_cookie_sets_response_with_remote_user(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
|
$item->set('alice');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($pool, $domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||||
|
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_valid_cookie_uses_auth_cookie_name_when_using_central_auth(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
|
$item->set('bob');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener($pool, $domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::AUTH_COOKIE_NAME, $ulid);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame('bob', $event->getResponse()->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── negative cases ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_no_cookie_sets_no_response(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($pool, $domainManager);
|
||||||
|
|
||||||
|
$event = $this->makeEvent(Request::create('/', 'GET'));
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cookie_without_session_sets_no_response(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($pool, $domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, 'unknown-ulid');
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_empty_cookie_value_sets_no_response(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($pool, $domainManager);
|
||||||
|
|
||||||
|
// cookies->set with empty string
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, '');
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
// empty cookie value should not be treated as a valid session
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Remote-User header modes ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_remote_user_session_mode_sends_session_id(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
|
$item->set('alice');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
$pool,
|
||||||
|
$domainManager,
|
||||||
|
$this->makeConfig(remoteUserMode: 'session'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_remote_user_static_mode_sends_fixed_value(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
|
$item->set('alice');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
$pool,
|
||||||
|
$domainManager,
|
||||||
|
$this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_remote_user_mapped_mode_sends_mapped_value(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
|
$item->set('alice');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
$pool,
|
||||||
|
$domainManager,
|
||||||
|
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_remote_user_mapped_mode_falls_back_to_session_id_when_not_in_map(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
|
$item->set('unknown_user');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
$pool,
|
||||||
|
$domainManager,
|
||||||
|
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_remote_user_none_mode_omits_header(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
|
$item->set('alice');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
$pool,
|
||||||
|
$domainManager,
|
||||||
|
$this->makeConfig(remoteUserMode: 'none'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertFalse($event->getResponse()->headers->has('Remote-User'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\ConfigBag;
|
||||||
|
use App\Listener\AllowListener;
|
||||||
|
use App\Tests\Support\TotpTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
final class AllowListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
use TotpTestHelper;
|
||||||
|
|
||||||
|
private function makeListener(ArrayAdapter $pool, ConfigBag $config): AllowListener
|
||||||
|
{
|
||||||
|
$listener = new AllowListener($pool, $config);
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
|
return $listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Request $request): RequestEvent
|
||||||
|
{
|
||||||
|
return new RequestEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request,
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_valid_ip_session_sets_response_with_remote_user(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$item = $pool->getItem('ip_1.2.3.4');
|
||||||
|
$item->set('carol');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$config = $this->makeConfig(ipTtl: 1800);
|
||||||
|
$listener = $this->makeListener($pool, $config);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertSame('carol', $response->headers->get('Remote-User'));
|
||||||
|
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_ip_session_sets_no_response(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$config = $this->makeConfig(ipTtl: 1800);
|
||||||
|
$listener = $this->makeListener($pool, $config);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '9.9.9.9']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ip_access_disabled_sets_no_response(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
// even though there's a stored session, ip access is disabled
|
||||||
|
$item = $pool->getItem('ip_1.2.3.4');
|
||||||
|
$item->set('carol');
|
||||||
|
$pool->save($item);
|
||||||
|
|
||||||
|
$config = $this->makeConfig(ipTtl: 0);
|
||||||
|
$listener = $this->makeListener($pool, $config);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ip_access_disabled_does_not_check_cache(): void
|
||||||
|
{
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$config = $this->makeConfig(ipTtl: 0);
|
||||||
|
$listener = $this->makeListener($pool, $config);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
// when disabled, nothing should have been written/read as a session
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
self::assertFalse($pool->hasItem('ip_1.2.3.4'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\Listener\InterceptListener;
|
||||||
|
use App\Service\DomainManager;
|
||||||
|
use App\Tests\Support\ListenerTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
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\HttpKernelInterface;
|
||||||
|
|
||||||
|
final class InterceptListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
use ListenerTestHelper;
|
||||||
|
|
||||||
|
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||||
|
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
||||||
|
|
||||||
|
private function makeListener(
|
||||||
|
DomainManager $domainManager,
|
||||||
|
?CacheItemPoolInterface $nonceCache = null,
|
||||||
|
): InterceptListener {
|
||||||
|
$listener = new InterceptListener(
|
||||||
|
$this->makeConfig(),
|
||||||
|
$domainManager,
|
||||||
|
$this->makeTwig(),
|
||||||
|
);
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
$listener->setNonceCache($nonceCache ?? new ArrayAdapter());
|
||||||
|
|
||||||
|
return $listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Request $request): RequestEvent
|
||||||
|
{
|
||||||
|
return new RequestEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request,
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── central-auth redirect branch ─────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_redirects_to_auth_subdomain_when_host_matches_base_domain(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://app.example.com/dashboard', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode());
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
self::assertStringStartsWith('https://auth.example.com/?', $location);
|
||||||
|
// the return query should contain the original url
|
||||||
|
self::assertStringContainsString('return=', $location);
|
||||||
|
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_does_not_redirect_when_already_on_auth_subdomain(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://auth.example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
// should render login page, not redirect
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertNotSame(Response::HTTP_SEE_OTHER, $response->getStatusCode());
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── login page rendering branch ──────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_presents_login_page_with_unauthorized_status(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
|
$content = $response->getContent();
|
||||||
|
self::assertStringContainsString('<form', $content);
|
||||||
|
// the rendered page should embed a freshly generated nonce
|
||||||
|
self::assertStringContainsString('name="nonce"', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_generated_nonce_is_stored_in_cache(): void
|
||||||
|
{
|
||||||
|
$nonceCache = new ArrayAdapter();
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($domainManager, $nonceCache);
|
||||||
|
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
// exactly one nonce should now exist in the cache, marked valid
|
||||||
|
$found = false;
|
||||||
|
foreach ($nonceCache->getValues() as $key => $value) {
|
||||||
|
if (str_starts_with($key, 'test_') || preg_match('/^[A-Za-z0-9_.]+$/', $key)) {
|
||||||
|
$found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ArrayAdapter stores raw values; verify at least one item was saved
|
||||||
|
self::assertTrue(\count($nonceCache->getValues()) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_template_uses_post_form_when_on_auth_subdomain(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://auth.example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$content = $event->getResponse()->getContent();
|
||||||
|
// when on the auth subdomain, post=true so the form has method="post"
|
||||||
|
self::assertStringContainsString('method="post"', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_template_does_not_use_post_form_when_not_on_auth_subdomain(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$content = $event->getResponse()->getContent();
|
||||||
|
// not on auth subdomain, so the form should NOT have method="post"
|
||||||
|
self::assertStringNotContainsString('method="post"', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── invalid cookie pruning ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_invalid_cookie_is_cleared_when_present(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
// send a cookie that won't match any session (so AcceptListener didn't fire)
|
||||||
|
$request->cookies->set(self::COOKIE_NAME, 'stale-ulid');
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
// a Clear-Site-Data style clearCookie should produce a Set-Cookie that expires it
|
||||||
|
$cookies = $response->headers->getCookies();
|
||||||
|
$cleared = false;
|
||||||
|
foreach ($cookies as $cookie) {
|
||||||
|
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
|
||||||
|
$cleared = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_cookie_clearing_when_no_cookie_present(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(false, '');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame([], $response->headers->getCookies());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_cookie_uses_auth_cookie_name_with_central_auth(): void
|
||||||
|
{
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener($domainManager);
|
||||||
|
|
||||||
|
// request to auth subdomain with a stale auth-domain cookie
|
||||||
|
$request = Request::create('https://auth.example.com/', 'GET');
|
||||||
|
$request->cookies->set(self::AUTH_COOKIE_NAME, 'stale-ulid');
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
$cleared = false;
|
||||||
|
foreach ($response->headers->getCookies() as $cookie) {
|
||||||
|
if (self::AUTH_COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
|
||||||
|
$cleared = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertTrue($cleared, 'Expected the auth cookie to be cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\Data\Payload;
|
||||||
|
use App\Listener\LoginListener;
|
||||||
|
use App\Service\DomainManager;
|
||||||
|
use App\Service\LoginInterface;
|
||||||
|
use App\Tests\Support\ListenerTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
final class LoginListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
use ListenerTestHelper;
|
||||||
|
|
||||||
|
private const string HEADER_NAME = 'X-Preauth';
|
||||||
|
|
||||||
|
private function makeListener(
|
||||||
|
?LoginInterface $loginManager = null,
|
||||||
|
?DomainManager $domainManager = null,
|
||||||
|
?int $rateLimitRemaining = 5,
|
||||||
|
): LoginListener {
|
||||||
|
$listener = new LoginListener(
|
||||||
|
$this->makeTwig(),
|
||||||
|
$this->makeRateLimiterFactory($rateLimitRemaining ?? 5),
|
||||||
|
$domainManager ?? new DomainManager(false, ''),
|
||||||
|
$loginManager ?? $this->createStub(LoginInterface::class),
|
||||||
|
$this->makeConfig(),
|
||||||
|
);
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
$listener->setNonceCache(new ArrayAdapter());
|
||||||
|
|
||||||
|
return $listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Request $request): RequestEvent
|
||||||
|
{
|
||||||
|
return new RequestEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request,
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a base64url-encoded X-Preauth header value for a payload. */
|
||||||
|
private function encodePayload(array $data): string
|
||||||
|
{
|
||||||
|
$json = json_encode($data, \JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── no login attempt ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_no_header_and_no_post_returns_early_without_response(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_post_to_non_auth_subdomain_returns_early_without_response(): void
|
||||||
|
{
|
||||||
|
// POST only counts as a login attempt when on the auth subdomain
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener(domainManager: $domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://app.example.com/', 'POST');
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── successful login via header ──────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_successful_login_via_header_sets_response_from_manager(): void
|
||||||
|
{
|
||||||
|
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
|
||||||
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
|
$loginManager->method('checkToken')->willReturn($expected);
|
||||||
|
|
||||||
|
$listener = $this->makeListener(loginManager: $loginManager);
|
||||||
|
|
||||||
|
$payload = $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => '123456', 'nonce' => 'nonce-1', 'json' => true,
|
||||||
|
]);
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$request->headers->set(self::HEADER_NAME, $payload);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame($expected, $event->getResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_successful_login_via_post_to_auth_subdomain(): void
|
||||||
|
{
|
||||||
|
$expected = new Response('hi bob', 303, ['Location' => '/']);
|
||||||
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
|
$loginManager->method('checkToken')->willReturn($expected);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener(loginManager: $loginManager, domainManager: $domainManager);
|
||||||
|
|
||||||
|
$request = Request::create('https://auth.example.com/', 'POST', [
|
||||||
|
'username' => 'bob', 'totp' => '654321', 'nonce' => 'nonce-2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame($expected, $event->getResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── failed login ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_failed_login_returns_json_error_with_new_nonce(): void
|
||||||
|
{
|
||||||
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
|
|
||||||
|
$listener = $this->makeListener(loginManager: $loginManager);
|
||||||
|
|
||||||
|
$payload = $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true,
|
||||||
|
]);
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$request->headers->set(self::HEADER_NAME, $payload);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
// the TotpTestHelper::makeConfig default errorMessage is 'Error'
|
||||||
|
self::assertSame('Error', $body['message']);
|
||||||
|
self::assertNotEmpty($body['nonce']);
|
||||||
|
self::assertFalse($body['post']);
|
||||||
|
// username is echoed back (sanitized via makeCacheKey)
|
||||||
|
self::assertSame('alice', $body['username']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_login_html_response_when_json_false(): void
|
||||||
|
{
|
||||||
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
|
|
||||||
|
$listener = $this->makeListener(loginManager: $loginManager);
|
||||||
|
|
||||||
|
$payload = $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false,
|
||||||
|
]);
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$request->headers->set(self::HEADER_NAME, $payload);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
|
self::assertStringContainsString('<form', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_login_on_auth_subdomain_uses_post_form(): void
|
||||||
|
{
|
||||||
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
loginManager: $loginManager,
|
||||||
|
domainManager: $domainManager,
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false,
|
||||||
|
]);
|
||||||
|
$request = Request::create('https://auth.example.com/', 'GET');
|
||||||
|
$request->headers->set(self::HEADER_NAME, $payload);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$content = $event->getResponse()->getContent();
|
||||||
|
self::assertStringContainsString('method="post"', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── rate-limited (blocked) login ─────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_rate_limited_login_returns_teapot_when_teapot_enabled(): void
|
||||||
|
{
|
||||||
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
|
|
||||||
|
// limiter with 0 remaining tokens -> blocked
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
loginManager: $loginManager,
|
||||||
|
rateLimitRemaining: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true,
|
||||||
|
]);
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$request->headers->set(self::HEADER_NAME, $payload);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode());
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
// the TotpTestHelper::makeConfig default teapotTitle is 'Teapot'
|
||||||
|
self::assertSame('Teapot', $body['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limited_login_returns_too_many_requests_when_teapot_disabled(): void
|
||||||
|
{
|
||||||
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
|
|
||||||
|
$listener = new LoginListener(
|
||||||
|
$this->makeTwig(),
|
||||||
|
$this->makeRateLimiterFactory(0),
|
||||||
|
new DomainManager(false, ''),
|
||||||
|
$loginManager,
|
||||||
|
$this->makeConfig(teapot: false),
|
||||||
|
);
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
$listener->setNonceCache(new ArrayAdapter());
|
||||||
|
|
||||||
|
$payload = $this->encodePayload([
|
||||||
|
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true,
|
||||||
|
]);
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$request->headers->set(self::HEADER_NAME, $payload);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
// teapot disabled, so tooManyTitle is used; helper default is 'Too Many'
|
||||||
|
self::assertSame('Too Many', $body['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── invalid payload handling ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_invalid_header_payload_still_records_failure_and_responds(): void
|
||||||
|
{
|
||||||
|
$loginManager = $this->createMock(LoginInterface::class);
|
||||||
|
// checkToken should not be called with a null payload
|
||||||
|
$loginManager->expects(self::never())->method('checkToken');
|
||||||
|
|
||||||
|
$listener = $this->makeListener(loginManager: $loginManager);
|
||||||
|
|
||||||
|
// an un-decodable header value
|
||||||
|
$request = Request::create('https://example.com/', 'GET');
|
||||||
|
$request->headers->set(self::HEADER_NAME, '!!!not-valid-base64!!!');
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
// Payload::decode returns null, so checkToken is skipped, but a
|
||||||
|
// failure response is still produced (the rate limiter is consulted)
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_post_without_required_fields_does_not_attempt_login(): void
|
||||||
|
{
|
||||||
|
$loginManager = $this->createMock(LoginInterface::class);
|
||||||
|
$loginManager->expects(self::never())->method('checkToken');
|
||||||
|
|
||||||
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
loginManager: $loginManager,
|
||||||
|
domainManager: $domainManager,
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST to auth subdomain but missing the required fields
|
||||||
|
$request = Request::create('https://auth.example.com/', 'POST', ['username' => 'only-user']);
|
||||||
|
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
// Payload::load returns null (missing totp & nonce), so it falls through
|
||||||
|
// to the failure path and produces a response
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\Listener\PublicAccessListener;
|
||||||
|
use App\Service\DomainInterface;
|
||||||
|
use App\Service\PublicPathMatcher;
|
||||||
|
use App\Tests\Support\ListenerTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for PublicAccessListener.
|
||||||
|
*
|
||||||
|
* @covers \App\Listener\PublicAccessListener
|
||||||
|
*/
|
||||||
|
final class PublicAccessListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
use ListenerTestHelper;
|
||||||
|
|
||||||
|
private function makeListener(
|
||||||
|
string $publicPaths = '',
|
||||||
|
int $remainingTokens = 10,
|
||||||
|
?string $authSubdomain = null,
|
||||||
|
): PublicAccessListener {
|
||||||
|
$pathMatcher = new PublicPathMatcher($publicPaths);
|
||||||
|
|
||||||
|
$domainManager = $this->createStub(DomainInterface::class);
|
||||||
|
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
|
||||||
|
|
||||||
|
$listener = new PublicAccessListener(
|
||||||
|
$pathMatcher,
|
||||||
|
$domainManager,
|
||||||
|
$this->makeTwig(),
|
||||||
|
$this->makeRateLimiterFactory($remainingTokens),
|
||||||
|
);
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
|
return $listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Request $request): RequestEvent
|
||||||
|
{
|
||||||
|
return new RequestEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request,
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── feature disabled ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_no_public_paths_returns_without_response(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '');
|
||||||
|
|
||||||
|
$request = Request::create('/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── non-public path ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_non_public_path_returns_without_response(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**');
|
||||||
|
|
||||||
|
$request = Request::create('/private', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── public path within rate limit ─────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_public_path_within_rate_limit_returns200(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||||
|
// No Remote-User header for public access
|
||||||
|
self::assertFalse($response->headers->has('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── public path rate limited ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_public_path_over_rate_limit_returns429(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
|
||||||
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
|
self::assertTrue($response->headers->has('Retry-After'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limited_response_contains_error_template(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$content = $event->getResponse()->getContent();
|
||||||
|
// Default teapot template content (env.teapot is true in test helper)
|
||||||
|
self::assertStringContainsString('teapot', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── auth subdomain is never public ────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_auth_subdomain_request_is_skipped(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
publicPaths: '/**',
|
||||||
|
remainingTokens: 10,
|
||||||
|
authSubdomain: 'auth.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request to auth subdomain — should NOT be treated as public
|
||||||
|
$request = Request::create('https://auth.example.com/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── query string is ignored ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_query_string_is_ignored_for_path_matching(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public?foo=bar', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── domain-scoped paths ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_domain_scoped_path_matches_correct_host(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('https://code.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_domain_scoped_path_does_not_match_other_host(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('https://other.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── wildcard matching ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_single_wildcard_matching(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_single_wildcard_does_not_match_deep_path(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public/a/b', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 200 response includes remaining token count ───────────────────── */
|
||||||
|
|
||||||
|
public function test_ok_response_includes_retry_after_header(): void
|
||||||
|
{
|
||||||
|
// The 200 response includes a Retry-After header showing remaining tokens
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame('42', $response->headers->get('Retry-After'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\Listener\RejectListener;
|
||||||
|
use App\Tests\Support\ListenerTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
final class RejectListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
use ListenerTestHelper;
|
||||||
|
|
||||||
|
private function makeListener(
|
||||||
|
bool $teapot = true,
|
||||||
|
int $remainingTokens = 5,
|
||||||
|
): RejectListener {
|
||||||
|
$listener = new RejectListener(
|
||||||
|
$this->makeConfig(teapot: $teapot),
|
||||||
|
$this->makeTwig(),
|
||||||
|
$this->makeRateLimiterFactory($remainingTokens),
|
||||||
|
);
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
|
return $listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Request $request): RequestEvent
|
||||||
|
{
|
||||||
|
return new RequestEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request,
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blocked_request_returns_teapot_when_teapot_enabled(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode());
|
||||||
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blocked_request_returns_too_many_requests_when_teapot_disabled(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(teapot: false, remainingTokens: 0);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
|
||||||
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unblocked_request_sets_no_response(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(remainingTokens: 5);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
// consume(0) with remaining tokens > 0 should not block
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blocked_response_contains_error_template_content(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
||||||
|
|
||||||
|
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$content = $event->getResponse()->getContent();
|
||||||
|
// Twig escapes the apostrophe in "I'm a teapot" to '
|
||||||
|
self::assertStringContainsString('a teapot', $content);
|
||||||
|
self::assertStringContainsString('I refuse to brew coffee', $content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\Listener\SecurityHeadersListener;
|
||||||
|
use App\Service\DomainInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
final class SecurityHeadersListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
private function makeListener(?string $authSubdomain = null): SecurityHeadersListener
|
||||||
|
{
|
||||||
|
$domainManager = $this->createStub(DomainInterface::class);
|
||||||
|
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
|
||||||
|
|
||||||
|
return new SecurityHeadersListener($domainManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(
|
||||||
|
Response $response,
|
||||||
|
?Request $request = null,
|
||||||
|
int $requestType = HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
): ResponseEvent {
|
||||||
|
return new ResponseEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request ?? Request::create('https://example.com/', 'GET'),
|
||||||
|
$requestType,
|
||||||
|
$response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The emitted Cache-Control is normalized by Symfony (directives are
|
||||||
|
* reordered), so assert on directives rather than the exact string.
|
||||||
|
*/
|
||||||
|
private function assertNoStoreHeaders(Response $response): void
|
||||||
|
{
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('proxy-revalidate'));
|
||||||
|
self::assertSame('0', $response->headers->getCacheControlDirective('max-age'));
|
||||||
|
self::assertSame('0', $response->headers->getCacheControlDirective('s-maxage'));
|
||||||
|
self::assertSame('no-cache', $response->headers->get('Pragma'));
|
||||||
|
self::assertSame('0', $response->headers->get('Expires'));
|
||||||
|
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
|
||||||
|
self::assertSame('*', $response->headers->get('Vary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertNoAntiCachingHeaders(Response $response): void
|
||||||
|
{
|
||||||
|
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertNull($response->headers->get('Pragma'));
|
||||||
|
self::assertNull($response->headers->get('Expires'));
|
||||||
|
self::assertNull($response->headers->get('Surrogate-Control'));
|
||||||
|
self::assertNull($response->headers->get('Vary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── non-2xx: the login flow must not be cacheable ────────────────── */
|
||||||
|
|
||||||
|
public function test_login_page_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_redirect_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('', Response::HTTP_SEE_OTHER, [
|
||||||
|
'Location' => 'https://example.com/dashboard',
|
||||||
|
]);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
// the redirect target must survive
|
||||||
|
self::assertSame('https://example.com/dashboard', $response->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limited_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('<h1>teapot</h1>', Response::HTTP_I_AM_A_TEAPOT);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_server_error_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('error', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 2xx: authenticated / public grants stay untouched ────────────── */
|
||||||
|
|
||||||
|
public function test_successful_authenticated_response_is_not_touched(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('hi alice', Response::HTTP_OK, [
|
||||||
|
'Remote-User' => 'alice',
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
]);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
// "already authenticated" responses are consumed by the reverse
|
||||||
|
// proxy's forward_auth check and never reach the browser, so they
|
||||||
|
// must not carry the anti-caching headers (or they could leak onto
|
||||||
|
// the protected service's own responses in custom configurations)
|
||||||
|
$this->assertNoAntiCachingHeaders($response);
|
||||||
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_successful_response_keeps_its_own_cache_headers(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('ok', Response::HTTP_OK, [
|
||||||
|
'Cache-Control' => 'public, max-age=60',
|
||||||
|
]);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
// the service's caching decisions are its own business;
|
||||||
|
// Symfony normalizes directive order, so assert semantically
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('public'));
|
||||||
|
self::assertSame('60', $response->headers->getCacheControlDirective('max-age'));
|
||||||
|
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── sub-requests ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_sub_requests_are_skipped(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response, null, HttpKernelInterface::SUB_REQUEST);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertNull($response->headers->get('X-Frame-Options'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the pre-existing security headers ────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_security_headers_are_applied(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
|
||||||
|
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
|
||||||
|
self::assertSame('strict-origin-when-cross-origin', $response->headers->get('Referrer-Policy'));
|
||||||
|
self::assertSame('max-age=31536000', $response->headers->get('Strict-Transport-Security'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_csp_allows_same_origin_connect_when_inline_script_is_used(): void
|
||||||
|
{
|
||||||
|
// not on the auth subdomain: the login form uses an inline fetch()
|
||||||
|
$listener = $this->makeListener('auth.example.com');
|
||||||
|
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertStringContainsString("connect-src 'self';", $response->headers->get('Content-Security-Policy'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_csp_does_not_allow_connect_when_on_auth_subdomain(): void
|
||||||
|
{
|
||||||
|
// on the auth subdomain the form POSTs normally — no inline fetch
|
||||||
|
$listener = $this->makeListener('auth.example.com');
|
||||||
|
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$request = Request::create('https://auth.example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($response, $request);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('connect-src', $response->headers->get('Content-Security-Policy'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit;
|
||||||
|
|
||||||
|
use App\MonitorCacheKeys;
|
||||||
|
use OutOfBoundsException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
|
||||||
|
final class MonitorCacheKeysTest extends TestCase
|
||||||
|
{
|
||||||
|
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
|
||||||
|
{
|
||||||
|
$pool ??= new ArrayAdapter();
|
||||||
|
|
||||||
|
return new MonitorCacheKeys($pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_constructor_initializes_empty_pool(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
self::assertSame([], $monitor->getKeys());
|
||||||
|
self::assertSame([], $monitor->getChanges());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_save_adds_key_and_tracks_change(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('alpha');
|
||||||
|
$item->set('value');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
self::assertSame(['alpha'], $monitor->getKeys());
|
||||||
|
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_save_deferred_then_commit_adds_key(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('beta');
|
||||||
|
$item->set('value');
|
||||||
|
$monitor->saveDeferred($item);
|
||||||
|
|
||||||
|
// saveDeferred calls update() which commits immediately
|
||||||
|
self::assertSame(['beta'], $monitor->getKeys());
|
||||||
|
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_item_returns_underlying_item(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('mykey');
|
||||||
|
$item->set('data');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
$fetched = $monitor->getItem('mykey');
|
||||||
|
self::assertTrue($fetched->isHit());
|
||||||
|
self::assertSame('data', $fetched->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_items_returns_multiple_items(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$a = $monitor->getItem('a');
|
||||||
|
$a->set(1);
|
||||||
|
$monitor->save($a);
|
||||||
|
$b = $monitor->getItem('b');
|
||||||
|
$b->set(2);
|
||||||
|
$monitor->save($b);
|
||||||
|
|
||||||
|
$items = $monitor->getItems(['a', 'b']);
|
||||||
|
$keys = [];
|
||||||
|
foreach ($items as $key => $item) {
|
||||||
|
$keys[$key] = $item->get();
|
||||||
|
}
|
||||||
|
self::assertSame(['a' => 1, 'b' => 2], $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_has_item_returns_true_for_existing_key(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('exists');
|
||||||
|
$item->set('v');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
self::assertTrue($monitor->hasItem('exists'));
|
||||||
|
self::assertFalse($monitor->hasItem('missing'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_item_removes_key_and_tracks_removal(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('doomed');
|
||||||
|
$item->set('v');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
$monitor->deleteItem('doomed');
|
||||||
|
|
||||||
|
self::assertSame([], $monitor->getKeys());
|
||||||
|
self::assertSame(['doomed' => MonitorCacheKeys::REMOVED], $monitor->getChanges());
|
||||||
|
self::assertFalse($monitor->hasItem('doomed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_item_on_missing_key_is_noop(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
$result = $monitor->deleteItem('nonexistent');
|
||||||
|
|
||||||
|
self::assertTrue($result);
|
||||||
|
self::assertSame([], $monitor->getKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_items_removes_multiple_keys(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
foreach (['x', 'y', 'z'] as $key) {
|
||||||
|
$item = $monitor->getItem($key);
|
||||||
|
$item->set($key);
|
||||||
|
$monitor->save($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
$monitor->deleteItems(['x', 'y']);
|
||||||
|
|
||||||
|
self::assertSame(['z'], $monitor->getKeys());
|
||||||
|
$changes = $monitor->getChanges();
|
||||||
|
self::assertSame(MonitorCacheKeys::REMOVED, $changes['x']);
|
||||||
|
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_items_with_missing_keys_still_returns_true(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
$result = $monitor->deleteItems(['ghost1', 'ghost2']);
|
||||||
|
|
||||||
|
self::assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clear_wipes_pool_when_not_empty(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('keep');
|
||||||
|
$item->set('v');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
$result = $monitor->clear();
|
||||||
|
|
||||||
|
self::assertTrue($result);
|
||||||
|
self::assertSame([], $monitor->getKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clear_is_noop_when_empty(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
$result = $monitor->clear();
|
||||||
|
|
||||||
|
self::assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mark_clean_resets_change_list(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('temp');
|
||||||
|
$item->set('v');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
self::assertNotEmpty($monitor->getChanges());
|
||||||
|
|
||||||
|
$monitor->markClean();
|
||||||
|
|
||||||
|
self::assertSame([], $monitor->getChanges());
|
||||||
|
self::assertSame(['temp'], $monitor->getKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_commit_passes_through(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
self::assertTrue($monitor->commit());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_save_key_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('__key_list');
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->save($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_save_change_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('__chg_list');
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->save($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_key_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->deleteItem('__key_list');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_change_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->deleteItem('__chg_list');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_items_with_key_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->deleteItems(['safe', '__key_list']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_items_with_change_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->deleteItems(['__chg_list']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_save_deferred_on_key_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('safe');
|
||||||
|
$item->set('value');
|
||||||
|
|
||||||
|
// getItem returns the real item, but saveDeferred calls update() which
|
||||||
|
// validates the key — so we need to get the __key_list item and try to save it
|
||||||
|
$keyListItem = $monitor->getItem('__key_list');
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->saveDeferred($keyListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_save_deferred_on_change_list_throws_out_of_bounds_exception(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$changeListItem = $monitor->getItem('__chg_list');
|
||||||
|
|
||||||
|
$this->expectException(OutOfBoundsException::class);
|
||||||
|
$monitor->saveDeferred($changeListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_keys_returns_empty_array_when_key_list_missing(): void
|
||||||
|
{
|
||||||
|
// If the underlying pool loses its key list, getKeys should return []
|
||||||
|
$pool = new ArrayAdapter();
|
||||||
|
$monitor = new MonitorCacheKeys($pool);
|
||||||
|
|
||||||
|
$item = $monitor->getItem('alpha');
|
||||||
|
$item->set('value');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
// delete the key list directly from the underlying pool
|
||||||
|
$pool->deleteItem('__key_list');
|
||||||
|
|
||||||
|
$monitor2 = new MonitorCacheKeys($pool);
|
||||||
|
// the constructor will re-initialize since __key_list is missing
|
||||||
|
// but getKeys on the new monitor should be empty
|
||||||
|
self::assertSame([], $monitor2->getKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_item_returns_true_for_existing_key(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
$item = $monitor->getItem('to-delete');
|
||||||
|
$item->set('value');
|
||||||
|
$monitor->save($item);
|
||||||
|
|
||||||
|
self::assertTrue($monitor->deleteItem('to-delete'));
|
||||||
|
self::assertNotContains('to-delete', $monitor->getKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_items_returns_true(): void
|
||||||
|
{
|
||||||
|
$monitor = $this->wrap();
|
||||||
|
foreach (['a', 'b', 'c'] as $key) {
|
||||||
|
$item = $monitor->getItem($key);
|
||||||
|
$item->set('value');
|
||||||
|
$monitor->save($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertTrue($monitor->deleteItems(['a', 'b', 'c']));
|
||||||
|
self::assertSame([], $monitor->getKeys());
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user