Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffb824c652 | ||
|
|
fed7b1b48c | ||
|
|
436450cdc2 | ||
|
|
69609db8af | ||
|
|
c84cf8c308 | ||
|
|
108e9623e6 | ||
|
|
0458d9b8d2 | ||
|
|
8cd06838d3 | ||
|
|
54990dafc6 | ||
|
|
06e2ca8b19 | ||
|
|
4d5afa20e9 | ||
|
|
74902c0fc7 | ||
|
|
bd2f5cbac3 | ||
|
|
51e921f54f | ||
|
|
ba8924a244 | ||
|
|
538bd74100 | ||
|
|
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 | ||
|
|
29e471c536 | ||
|
|
5563999525 |
Executable
+554
@@ -0,0 +1,554 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
# caller_disables <knob> <value>
|
||||
#
|
||||
# True when one of the repo's own *php-test pins* sets a shared-workflow input
|
||||
# to <value> — `run-composer-audit: 'false'`, `coverage: 'none'`. Accepts the
|
||||
# bare YAML boolean spelling and both quote styles, ignores indentation and
|
||||
# trailing comments, and only reads files that pin php-test.yaml (a
|
||||
# docker-publish caller's knobs are unrelated).
|
||||
#
|
||||
# "Any pin disables it" is deliberate: one workflow that switches the step off
|
||||
# is enough to make the check un-satisfied. Refusing to accept an opt-out
|
||||
# would turn this fix into a false green, which is the one outcome worse than
|
||||
# the false red it replaces.
|
||||
caller_disables() {
|
||||
local knob="$1" value="$2" got
|
||||
local pins=()
|
||||
mapfile -t pins < <(grep -rlE '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows/php-test\.yaml@' .gitea/workflows 2>/dev/null)
|
||||
[ "${#pins[@]}" -eq 0 ] && return 1
|
||||
got=$(grep -hE "^[[:space:]]*${knob}:" "${pins[@]}" 2>/dev/null \
|
||||
| sed 's/#.*//' | sed 's/^[^:]*://' | tr -d "[:space:]\042\047" \
|
||||
| tr '[:upper:]' '[:lower:]' || true)
|
||||
printf '%s\n' "$got" | grep -qx "$value"
|
||||
}
|
||||
|
||||
# ci_runs <inline-pattern> [<knob> <disabled-value>]
|
||||
#
|
||||
# "CI runs X" checks have TWO legitimate shapes, and only one existed when
|
||||
# they were written — before the move to shared workflows (§8.2(1)):
|
||||
#
|
||||
# 1. INLINE — this repo's own workflow contains the command. Grep it.
|
||||
# 2. DELEGATED — this repo pins private/ci's php-test.yaml, and the command
|
||||
# runs there. The text is deliberately NOT in this repo any more; the
|
||||
# caller is a ~15-line pin by design.
|
||||
#
|
||||
# Only shape 1 used to be accepted, which turned every adopted repo's audit
|
||||
# and validate checks red while the steps genuinely ran — a false positive
|
||||
# that reported a violation where there was none.
|
||||
#
|
||||
# For shape 2 the only local evidence is the knobs the caller passes, so that
|
||||
# is what gets checked: an explicit opt-out (`run-composer-audit: 'false'`,
|
||||
# `coverage: 'none'`) means the step does NOT run there, and the check must
|
||||
# keep failing. A check that accepts a switched-off step is a false green.
|
||||
#
|
||||
# Deliberately NOT verified here: that the shared pipeline still contains the
|
||||
# step. This script is vendored and stays offline (see the header), and
|
||||
# private/ci is LAN-only, so fetching it at run time would reintroduce exactly
|
||||
# the silent-no-op dependency that vendoring removed. That half of the
|
||||
# contract is guarded where the file lives: validate-workflows.py fails
|
||||
# private/ci's own CI if php-test.yaml loses a step the projects' checks take
|
||||
# on faith, or if a gate's default flips to disabled.
|
||||
ci_runs() {
|
||||
local pattern="$1" knob="${2:-}" disabled="${3:-}"
|
||||
if grep -rqE '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows/php-test\.yaml@' .gitea/workflows 2>/dev/null; then
|
||||
# Delegated: the caller's knobs are the only local source of truth.
|
||||
[ -n "$knob" ] && caller_disables "$knob" "$disabled" && return 1
|
||||
return 0
|
||||
fi
|
||||
any_file_contains "$pattern" .gitea/workflows
|
||||
}
|
||||
|
||||
# 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"
|
||||
|
||||
# These three accept the command either inline or via the shared pipeline —
|
||||
# see ci_runs above for why, and for what is still required of a delegating
|
||||
# caller (the opt-out knobs must not be set).
|
||||
check "ci-composer-audit" \
|
||||
"CI runs 'composer audit'" \
|
||||
"§8.1" \
|
||||
ci_runs 'composer audit' run-composer-audit false
|
||||
|
||||
check "ci-coverage" \
|
||||
"CI measures test coverage" \
|
||||
"§2.3" \
|
||||
ci_runs 'coverage' coverage none
|
||||
|
||||
check "ci-composer-validate" \
|
||||
"CI runs 'composer validate --strict'" \
|
||||
"§8.3" \
|
||||
ci_runs 'composer validate'
|
||||
|
||||
# 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
|
||||
|
||||
# A bake file describes WHICH Dockerfile stage to build. buildx does not check
|
||||
# that the stage exists until build time, and `bake --print` — the obvious way
|
||||
# to validate one — happily resolves a target that names no stage, because it
|
||||
# never reads the Dockerfile. So a typo there reaches CI and fails after the
|
||||
# push. This is the check that buildx is missing.
|
||||
#
|
||||
# SKIPPED when there is no bake file: most repos use the `action` backend and
|
||||
# have none, and absence is not a violation.
|
||||
#
|
||||
# The helper belongs in the PREREQUISITE, not only in the command. It used to
|
||||
# be `[ -f ... ] || exit 0` INSIDE the command, which is a different thing: a
|
||||
# repo that has a bake file but no vendored helper reported a green tick for a
|
||||
# check that never ran. That is the one outcome this script's header singles
|
||||
# out as most dangerous, and it was live — preauth adopted a bake file before
|
||||
# the `validate-bake.py` half of the re-vendor landed, so its next sync would
|
||||
# have shown a green `bake-target-exists` no matter what the bake file said.
|
||||
#
|
||||
# With the helper in the prerequisite the same state reports SKIPPED — its own
|
||||
# yellow state, explicitly not a pass. (`css-control-size.py`, the other
|
||||
# helper, has been wired this way since it was added; see below.)
|
||||
check_opt "bake-target-exists" \
|
||||
"docker-bake.hcl targets a stage that exists in the Dockerfile" \
|
||||
"§6.2" \
|
||||
'command -v python3 >/dev/null 2>&1 && [ -f docker-bake.hcl ] && [ -f "$CONFORMANCE_DIR/validate-bake.py" ]' \
|
||||
bash -c 'exec "$CONFORMANCE_DIR/validate-bake.py" docker-bake.hcl Dockerfile'
|
||||
|
||||
$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())
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
validate-bake.py — check a docker-bake.hcl against its Dockerfile.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`docker buildx bake --print` resolves the bake file but does NOT read the
|
||||
Dockerfile, so it happily accepts `target = "app"` when no stage is named
|
||||
`app`. The failure only surfaces at build time, in CI, after the push:
|
||||
|
||||
ERROR: failed to solve: target stage "app" could not be found
|
||||
|
||||
That is exactly the failure preauth had: its final stage was unnamed
|
||||
(`FROM dunglas/frankenphp:php8.5-trixie`), so `target = "app"` could never
|
||||
have resolved. --print reported success.
|
||||
|
||||
This script checks the cross-file contract that buildx does not:
|
||||
1. every `target = "..."` matches a named Dockerfile stage
|
||||
2. the named stage is the LAST one, so a plain `docker build` still works
|
||||
3. every variable the file references is declared
|
||||
4. the `default` group only names targets that exist
|
||||
|
||||
Works without a Docker daemon, so it is usable in CI and on the workstation.
|
||||
|
||||
Usage: validate-bake.py [bake-file] [dockerfile]
|
||||
Exit: 0 = clean, 1 = problems found
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
FROM_RE = re.compile(r"^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?\s*$", re.I)
|
||||
TARGET_RE = re.compile(r'^\s*target\s*=\s*"([^"]+)"', re.M)
|
||||
DECL_RE = re.compile(r'^\s*target\s+"([^"]+)"\s*\{', re.M)
|
||||
VAR_DECL_RE = re.compile(r'^\s*variable\s+"([^"]+)"\s*\{', re.M)
|
||||
VAR_USE_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
GROUP_RE = re.compile(r'^\s*group\s+"([^"]+)"\s*\{', re.M)
|
||||
TARGETS_LIST_RE = re.compile(r"targets\s*=\s*\[([^\]]*)\]")
|
||||
|
||||
|
||||
def stages(dockerfile: Path) -> list[tuple[int, str, str]]:
|
||||
"""Return (line_no, image, stage_name_or_empty) for each FROM."""
|
||||
out = []
|
||||
for i, line in enumerate(dockerfile.read_text().splitlines(), 1):
|
||||
if m := FROM_RE.match(line):
|
||||
out.append((i, m.group(1), m.group(2) or ""))
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
bake = Path(sys.argv[1] if len(sys.argv) > 1 else "docker-bake.hcl")
|
||||
dockerfile = Path(sys.argv[2] if len(sys.argv) > 2 else "Dockerfile")
|
||||
|
||||
for f in (bake, dockerfile):
|
||||
if not f.is_file():
|
||||
print(f" ✗ missing file: {f}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
text = bake.read_text()
|
||||
found_stages = stages(dockerfile)
|
||||
named = [(ln, n) for ln, _, n in found_stages if n]
|
||||
names = [n for _, n in named]
|
||||
|
||||
problems: list[str] = []
|
||||
notes: list[str] = []
|
||||
|
||||
# 1 + 2: target/stage contract
|
||||
for t in TARGET_RE.findall(text):
|
||||
if t not in names:
|
||||
problems.append(
|
||||
f"bake target '{t}' matches no named Dockerfile stage. "
|
||||
f"Named stages: {names or '(none)'}. "
|
||||
f"buildx --print does NOT catch this; the build fails."
|
||||
)
|
||||
if names:
|
||||
# A plain `docker build` builds the LAST stage. If no bake target
|
||||
# points at it, the two build paths produce different images — worth
|
||||
# knowing, but legitimate for repos that publish one variant per
|
||||
# target (task-weaver builds controller + worker and never uses the
|
||||
# bare `docker build` path). So: note, not error.
|
||||
bake_targets = TARGET_RE.findall(text)
|
||||
last_name = named[-1][1]
|
||||
if bake_targets and last_name not in bake_targets:
|
||||
notes.append(
|
||||
f"no bake target selects the LAST Dockerfile stage "
|
||||
f"('{last_name}'), so a plain `docker build` and `bake` "
|
||||
f"produce different images."
|
||||
)
|
||||
else:
|
||||
problems.append("Dockerfile has no named stages; bake needs one.")
|
||||
|
||||
# 3: declared vs used variables
|
||||
declared = set(VAR_DECL_RE.findall(text))
|
||||
used = set(VAR_USE_RE.findall(text))
|
||||
for u in sorted(used - declared):
|
||||
problems.append(
|
||||
f"${{{u}}} is used but never declared as a `variable` block; "
|
||||
f"bake would error at load time."
|
||||
)
|
||||
for d in sorted(declared - used):
|
||||
notes.append(f"variable '{d}' is declared but never referenced.")
|
||||
|
||||
# 4: default group names real targets
|
||||
declared_targets = set(DECL_RE.findall(text))
|
||||
for gname in GROUP_RE.findall(text):
|
||||
block = text.split(f'group "{gname}"', 1)[1][:400]
|
||||
if m := TARGETS_LIST_RE.search(block):
|
||||
for t in re.findall(r'"([^"]+)"', m.group(1)):
|
||||
if t not in declared_targets:
|
||||
problems.append(
|
||||
f"group '{gname}' references target '{t}', "
|
||||
f"which is not declared."
|
||||
)
|
||||
|
||||
name = bake.name
|
||||
if problems:
|
||||
print(f" FAIL {name}")
|
||||
for p in problems:
|
||||
print(f" ✗ {p}")
|
||||
else:
|
||||
print(f" OK {name} (targets: {sorted(declared_targets)} / "
|
||||
f"stages: {names})")
|
||||
for n in notes:
|
||||
print(f" · {n}")
|
||||
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+76
-4
@@ -1,12 +1,84 @@
|
||||
.git/
|
||||
# .dockerignore — defines the build context used by `COPY . .`.
|
||||
#
|
||||
# Ignoring a path keeps EVERYTHING under it out of the context, which is what
|
||||
# makes "copy the tree instead of listing files" safe: the allowlist that used
|
||||
# to live in eight `COPY ./x /app/x` lines is now simply the absence of a
|
||||
# pattern here. The failure mode is inverted — a missed entry fails the build
|
||||
# loudly ("not found in build context") instead of silently baking a file in.
|
||||
#
|
||||
# Rules that matter (Guiding Light §6.3): `config/`, `bin/`, `public/` and the
|
||||
# composer manifests are never ignored, and /Caddyfile is the inverse case —
|
||||
# it is ignored, because the final stage copies it from `docker/Caddyfile`.
|
||||
|
||||
# ── VCS & CI ────────────────────────────────────────────────────────────────
|
||||
.git
|
||||
.gitignore
|
||||
.editorconfig
|
||||
.gitea
|
||||
.ci
|
||||
|
||||
# ── Secrets: never bake these into a layer (§6.1, §8.12) ────────────────────
|
||||
# Bare `.env` included deliberately: it is exactly the file a developer has
|
||||
# locally and must not land in an image layer.
|
||||
.env
|
||||
.env.local
|
||||
.env.local.php
|
||||
.env.*.local
|
||||
.env.dev
|
||||
.env.test
|
||||
host.env
|
||||
config/secrets/prod/prod.decrypt.private.php
|
||||
|
||||
# ── Local runtime state (§5.2) ──────────────────────────────────────────────
|
||||
# var/ holds the dev cache and logs; the prod cache is warmed inside the image.
|
||||
var/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# ── Rebuilt inside the image from composer.lock ─────────────────────────────
|
||||
vendor/
|
||||
|
||||
# ── Dev / test artefacts not needed at runtime ──────────────────────────────
|
||||
tests/
|
||||
.phpunit.cache/
|
||||
.phpunit.result.cache
|
||||
phpunit.xml
|
||||
phpunit.dist.xml
|
||||
.php-cs-fixer.cache
|
||||
.php-cs-fixer.dist.php
|
||||
.php-cs-fixer.php
|
||||
phpstan.neon.dist
|
||||
phpstan-baseline.neon
|
||||
|
||||
# ── Build inputs that aren't payload ────────────────────────────────────────
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-bake.hcl
|
||||
compose*.yaml
|
||||
docker-compose*.yaml
|
||||
|
||||
# ── Docs, examples and host-side tooling ────────────────────────────────────
|
||||
# The root Caddyfile is the host-side example (docs/examples/ has a longer
|
||||
# one); the image's config is docker/Caddyfile, copied explicitly below.
|
||||
docs/
|
||||
*.md
|
||||
.env
|
||||
.env.test
|
||||
.env.local
|
||||
license.txt
|
||||
Caddyfile
|
||||
Domainfile
|
||||
run.sh
|
||||
deploy.sh
|
||||
bin/composer
|
||||
bin/dev.sh
|
||||
bin/franken.sh
|
||||
bin/phpunit
|
||||
composer.phar
|
||||
|
||||
# ── Generated config reference (regenerate with config:dump) ────────────────
|
||||
config/reference.php
|
||||
|
||||
# ── Editor / OS noise ───────────────────────────────────────────────────────
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
+41
-5
@@ -1,20 +1,56 @@
|
||||
# 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
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = 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]
|
||||
indent_style = tab
|
||||
|
||||
[{compose.yaml,compose.*.yaml}]
|
||||
indent_size = 2
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
[*.{sh,bash}]
|
||||
indent_size = 4
|
||||
|
||||
@@ -12,6 +12,19 @@ BURST_COUNT=10
|
||||
BURST_TIME=30
|
||||
UPPER_COUNT=100
|
||||
UPPER_TIME=3600
|
||||
PASSKEY_ENABLED=0
|
||||
PASSKEY_RP_NAME=''
|
||||
PASSKEY_USER_VERIFICATION='required'
|
||||
PASSKEY_TIMEOUT=60000
|
||||
PASSKEY_BUTTON_NAME='Sign in with a passkey'
|
||||
PASSKEY_REGISTER_NAME='Register this device as a passkey'
|
||||
PASSKEY_BEGIN_BURST_COUNT=30
|
||||
PASSKEY_BEGIN_BURST_TIME=60
|
||||
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'
|
||||
|
||||
@@ -1,33 +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'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
|
||||
with:
|
||||
mode: develop
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Passed explicitly from repo vars
|
||||
image-target: ${{ vars.DOCKERHUB_TARGET }}
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# docker-bake.hcl controls building
|
||||
build-backend: 'bake'
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_TARGET }}:develop
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# 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:
|
||||
@@ -7,31 +11,16 @@ on:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
|
||||
with:
|
||||
mode: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Passed explicitly from repo vars
|
||||
image-target: ${{ vars.DOCKERHUB_TARGET }}
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# docker-bake.hcl controls building
|
||||
build-backend: 'bake'
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_TARGET }}:latest
|
||||
${{ vars.DOCKERHUB_TARGET }}:${{ steps.version.outputs.VERSION }}
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
# Sync GitHub - upload branch change to github, via the shared workflow
|
||||
|
||||
name: Sync GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
- 'main'
|
||||
- 'feat*'
|
||||
- 'fix*'
|
||||
- 'cleanup*'
|
||||
- 'chore*'
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
uses: private/ci/.gitea/workflows/sync-github.yaml@v1
|
||||
with:
|
||||
sync-target: ${{ vars.SYNC_GITHUB_TARGET }}
|
||||
|
||||
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
|
||||
# by default we do not alter existing github tags, but that can be changed here.
|
||||
# force-tags: true
|
||||
|
||||
secrets:
|
||||
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||
|
||||
+27
-20
@@ -1,36 +1,43 @@
|
||||
# Tests - ensure code quality, via the shared workflow.
|
||||
#
|
||||
# Checks include: PHPStan, PHPUnit, and PHP-CS-Fixer.
|
||||
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
- 'feat*'
|
||||
- 'fix*'
|
||||
- 'cleanup*'
|
||||
- 'chore*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
uses: private/ci/.gitea/workflows/php-test.yaml@v1
|
||||
with:
|
||||
php-version: '8.5'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# profiles defines what to test:
|
||||
# * web-app: full test suite (default)
|
||||
# * auth-gateway: skip template check
|
||||
# * api-gateway: skip interface checks
|
||||
profile: auth-gateway
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.5'
|
||||
extensions: apcu, mbstring
|
||||
coverage: xdebug
|
||||
ini-values: apc.enable_cli=1
|
||||
# coverage defines how to check test-coverage:
|
||||
# * pcov: recommended (default)
|
||||
# * xdebug
|
||||
coverage: 'pcov'
|
||||
# 0-100 percentage of test-coverage required
|
||||
coverage-min: '75'
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
# does failing our "conformance" check make the test suite fail
|
||||
conformance-blocking: false
|
||||
|
||||
- name: Run php-cs-fixer
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
|
||||
- name: Run tests
|
||||
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
|
||||
secrets:
|
||||
# github token so composer can download dependencies
|
||||
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||
|
||||
+68
-13
@@ -1,18 +1,73 @@
|
||||
<?php
|
||||
|
||||
$finder = (new PhpCsFixer\Finder())
|
||||
->in(__DIR__)
|
||||
->exclude('var')
|
||||
->exclude('vendor')
|
||||
->notPath([
|
||||
'config/bundles.php',
|
||||
'config/reference.php',
|
||||
])
|
||||
;
|
||||
declare(strict_types=1);
|
||||
|
||||
return (new PhpCsFixer\Config())
|
||||
/**
|
||||
* .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([
|
||||
'@PSR12' => true,
|
||||
'@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($finder)
|
||||
;
|
||||
->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)
|
||||
);
|
||||
|
||||
+119
-1
@@ -5,7 +5,125 @@ 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]
|
||||
## [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.
|
||||
|
||||
### Changed
|
||||
- **Dockerfile rebuild — same layout as the rest of the portfolio** (Guiding
|
||||
Light §6.4). The build now copies the tree (`COPY . .`) and lets
|
||||
`.dockerignore` decide what reaches the context, instead of maintaining a
|
||||
hand-written `COPY ./x /app/x` allowlist that had to be kept in step with
|
||||
the project layout. `var/` — which the old file list never copied — now
|
||||
simply stays out via the ignore file.
|
||||
- **The image runs as a non-root `app` user** (uid/gid 1000, the same
|
||||
convention as task-loom/context-shuttle). `/data` (cache pools) and
|
||||
`/config` are created and owned by it. This resolves the last failing
|
||||
conformance check (§6.4 `dockerfile-nonroot`).
|
||||
- **`.dockerignore` rebuilt on the Guiding Light §6.2 baseline** — in
|
||||
particular `.env` is now excluded explicitly (§6.1), so a developer's
|
||||
local environment file can never be baked into a layer.
|
||||
- **`docker/php.ini` and `docker/Caddyfile` added.** The PHP overrides
|
||||
(`expose_php=Off`, error/log settings, OPcache timestamps off, APCu for
|
||||
CLI) and the FrankenPHP app config now live in the repository instead of
|
||||
being three heredocs inside the Dockerfile, so what the image runs is
|
||||
reviewable in a diff.
|
||||
- **Runtime base image pinned to `dunglas/frankenphp:1-php8.5-trixie` and
|
||||
APCu installed via the base image's `install-php-extensions`** — the
|
||||
versioned tag replaces the floating one, and the build no longer drags a
|
||||
compiler toolchain into the runtime layer to build one extension.
|
||||
- **`/app` is now the whole project.** The old image only shipped
|
||||
`bin/console`, `config`, `public`, `src`, `templates` and the composer
|
||||
manifests; `config/reference.php` and other loose files are now present.
|
||||
No application path changes: `public/index.php` and `bin/console` resolve
|
||||
through the same relative paths.
|
||||
- `bin/franken.sh` mounts the share dir at its new default
|
||||
(`/app/var/share`) instead of the old `/app/var/share` bind that no longer
|
||||
matched the image.
|
||||
|
||||
### Fixed
|
||||
- **`composer dump-env prod --empty` removed.** preauth does not depend on
|
||||
`symfony/dotenv` (it is not in `composer.lock`), so nothing reads a `.env`
|
||||
file in the container — the command only produced a dead
|
||||
`.env.local.php` in the build stage. The Dockerfile comment that claimed
|
||||
otherwise is gone with it.
|
||||
- **`composer install` no longer ships a classmap missing `App\`.** The old
|
||||
build ran `install --optimize-autoloader` before `src/` was copied, and the
|
||||
final `--classmap-authoritative` dump happened before any `COPY . .`; the
|
||||
classmap is now rebuilt after the application is in place.
|
||||
- **The HEALTHCHECK can actually pass.** It probed `curl -f http://localhost/`,
|
||||
and preauth answers every unauthenticated request to `/` with the login page
|
||||
and a `401` — so the probe failed 100% of the time and the container was
|
||||
permanently marked unhealthy. It now probes Caddy's loopback admin endpoint
|
||||
(the base image's own default probe, restated explicitly), which is why the
|
||||
Caddyfile deliberately does not disable the admin API.
|
||||
- **`expose_php` is now genuinely off in the runtime image.** The base image
|
||||
ships the `php.ini-production` *template* but no active `php.ini`, so the
|
||||
previous `cp` of the template was the only thing setting it — and the
|
||||
`docker/php.ini` overrides are loaded after it, so stating it here makes the
|
||||
intent explicit; verified against a real boot that no `X-Powered-By` header
|
||||
is emitted.
|
||||
- `bin/franken.sh` no longer passes `DEFAULT_URI`, which the application does
|
||||
not read (`config/packages/routing.yaml` sets the router's `default_uri`).
|
||||
|
||||
## [1.0.0] — v1.0 Release
|
||||
|
||||
### Security
|
||||
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
|
||||
|
||||
@@ -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://
|
||||
root public/
|
||||
rewrite index.php
|
||||
|
||||
+110
-54
@@ -1,72 +1,128 @@
|
||||
# use build image, to simplify final image
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# PreAuth — one app, one image.
|
||||
#
|
||||
# Multi-stage FrankenPHP build: dependencies in a builder, the runtime image
|
||||
# only gets the finished tree. Runtime: FrankenPHP worker mode, non-root,
|
||||
# state on /data. TLS is terminated upstream of the container; FrankenPHP
|
||||
# serves :80.
|
||||
#
|
||||
# Build context: the whole tree (`COPY . .`), narrowed by .dockerignore. The
|
||||
# allowlist that used to live in the eight `COPY ./x /app/x` below is in that
|
||||
# file now — a directory that must ship is a directory it does not exclude.
|
||||
#
|
||||
# Secrets are injected at runtime as env vars, never baked in (§8.12).
|
||||
|
||||
# ── Stage: build — composer dependencies + prod app ────────────────────────
|
||||
FROM php:8.5-trixie AS build
|
||||
|
||||
# install APCu and composer
|
||||
RUN pecl install apcu && \
|
||||
docker-php-ext-enable apcu
|
||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y unzip git
|
||||
# Build-time set: git (composer resolves packages over VCS) and unzip (dist
|
||||
# extraction). Neither reaches the runtime image.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# symfony required environment variables
|
||||
ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
# APCu and Composer, both only needed to compile the application.
|
||||
RUN pecl install apcu \
|
||||
&& docker-php-ext-enable apcu
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# load application into build image
|
||||
RUN mkdir -p /data/preauth
|
||||
RUN mkdir -p /app/bin
|
||||
WORKDIR /app
|
||||
COPY ./bin/console /app/bin/console
|
||||
COPY ./config /app/config
|
||||
COPY ./public /app/public
|
||||
COPY ./src /app/src
|
||||
COPY ./templates /app/templates
|
||||
COPY ./composer.json /app/composer.json
|
||||
COPY ./composer.lock /app/composer.lock
|
||||
COPY ./symfony.lock /app/symfony.lock
|
||||
|
||||
# install application dependencies
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
RUN composer dump-env prod --empty
|
||||
# Manifests first so the dependency layer only rebuilds when they change.
|
||||
COPY composer.json composer.lock symfony.lock ./
|
||||
RUN composer install --no-dev --no-interaction --prefer-dist \
|
||||
--optimize-autoloader --no-scripts
|
||||
|
||||
# start creating final image
|
||||
FROM dunglas/frankenphp:php8.5-trixie
|
||||
# Copy the application. .dockerignore keeps vendor/, var/, tests/ and the
|
||||
# local env files out of the context; composer install has already run, so
|
||||
# its vendor/ wins.
|
||||
COPY . .
|
||||
|
||||
# install APCu and curl (needed for healthcheck)
|
||||
RUN pecl install apcu && \
|
||||
docker-php-ext-enable apcu
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# src/ was not in the context when composer install ran, so the authoritative
|
||||
# classmap has to be rebuilt now that the application code is present.
|
||||
#
|
||||
# There is deliberately no `composer dump-env` step: preauth does not depend
|
||||
# on symfony/dotenv (it is absent from composer.lock), so nothing reads a
|
||||
# .env file at runtime and the dump would only add a dead file. Runtime
|
||||
# configuration comes from the environment, with the defaults documented in
|
||||
# config/services.yaml.
|
||||
RUN composer dump-autoload --classmap-authoritative --no-dev
|
||||
|
||||
# symfony required environment variables
|
||||
ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
# Build-time smoke of the autoloader + config compile. No APP_SECRET is
|
||||
# needed: %env(APP_SECRET)% is not resolved at compile time, and the cache is
|
||||
# cleared afterwards anyway — the real warm-up runs at container start with
|
||||
# the injected secrets (entrypoint; §8.12).
|
||||
#
|
||||
# The cache is written to the share dir, not var/cache: the runtime image
|
||||
# ships without a warmed var/cache, so the first container start does the
|
||||
# build for its own APP_SECRET (and the pages/ filesystem pool needs a
|
||||
# writable dir owned by the app user).
|
||||
RUN APP_ENV=prod APP_SHARE_DIR=/data/preauth bin/console cache:warmup \
|
||||
&& rm -rf var/cache/* var/log/*
|
||||
|
||||
# ── Stage: app — the runtime image ─────────────────────────────────────────
|
||||
FROM dunglas/frankenphp:1-php8.5-trixie AS app
|
||||
|
||||
# Runtime set: curl is the HEALTHCHECK's probe; APCu is the state store.
|
||||
# The base image ships the install-php-extensions script, which builds the
|
||||
# extension and removes its own build dependencies afterwards — so git,
|
||||
# autoconf and gcc never reach this stage the way `pecl install` needed them.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& install-php-extensions apcu \
|
||||
&& install-php-extensions intl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# PHP configuration. The packaged production baseline is copied in first
|
||||
# (the base image ships the template, not an active php.ini), then the app's
|
||||
# own overrides are layered on top of it — they restate the security-critical
|
||||
# switches so the intent survives a base-image default changing underneath us.
|
||||
COPY docker/php.ini $PHP_INI_DIR/conf.d/zz-preauth.ini
|
||||
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
|
||||
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
|
||||
RUN chmod +x /usr/local/bin/entrypoint
|
||||
|
||||
# load application into final image
|
||||
WORKDIR /app
|
||||
COPY --from=build /data/preauth /data/preauth
|
||||
COPY --from=build /app /app
|
||||
|
||||
# configure container
|
||||
COPY ./Caddyfile /etc/frankenphp/Caddyfile
|
||||
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
|
||||
RUN echo 'expose_php = off' > $PHP_INI_DIR/conf.d/restrict.ini
|
||||
# console needs apc to manage cache
|
||||
RUN echo 'apc.enable_cli = on' > $PHP_INI_DIR/conf.d/console.ini
|
||||
# Non-root runtime user (Guiding Light §6.4). uid/gid 1000, same convention
|
||||
# as task-loom/task-weaver/context-shuttle. /data holds the cache pools the
|
||||
# app writes at runtime, /config is Caddy's own XDG dir.
|
||||
RUN groupadd --system --gid 1000 app \
|
||||
&& useradd --system --uid 1000 --gid app \
|
||||
--home-dir /app --shell /usr/sbin/nologin app \
|
||||
&& mkdir -p /data/preauth /config \
|
||||
&& chown -R app:app /app /data
|
||||
|
||||
# app uses var folder for cache storage
|
||||
USER app
|
||||
|
||||
# FrankenPHP listens on :80; TLS is terminated by the external proxy.
|
||||
# APP_SHARE_DIR points the filesystem cache pools (sessions, rate limiter)
|
||||
# at the volume. MAX_REQUESTS is a build arg so images can bake in a
|
||||
# different worker-recycle default; the Caddyfile placeholder reads it.
|
||||
ARG MAX_REQUESTS=500
|
||||
ENV APP_ENV=prod \
|
||||
APP_DEBUG=0 \
|
||||
APP_SHARE_DIR=/data/preauth \
|
||||
SERVER_NAME=:80 \
|
||||
MAX_REQUESTS=$MAX_REQUESTS
|
||||
|
||||
# Persistent state: cache pools (sessions, backup codes, rate limits) and
|
||||
# Caddy's data. Only /data is needed at runtime; /config is declared because
|
||||
# the base image points XDG_CONFIG_HOME at it.
|
||||
VOLUME ["/config", "/data"]
|
||||
|
||||
# runs http on standard port
|
||||
EXPOSE 80
|
||||
|
||||
# healthcheck
|
||||
HEALTHCHECK --interval=5m \
|
||||
--retries=3 \
|
||||
--start-interval=1s \
|
||||
--start-period=10s \
|
||||
--timeout=2s \
|
||||
CMD curl http://localhost || exit 1
|
||||
# Liveness: Caddy's own admin endpoint, bound to loopback inside the
|
||||
# container, exactly as the base image declares it (restated here so the
|
||||
# probe does not depend on the upstream default staying put). The app's own
|
||||
# routes cannot serve this: an unauthenticated request gets the login page
|
||||
# with a 401, so `curl -f` against HTTP would always report unhealthy.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:2019/metrics || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint"]
|
||||
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
|
||||
|
||||
@@ -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.
|
||||
+42
-49
@@ -10,7 +10,7 @@ authentication — it's a gate that prevents outsiders from even seeing
|
||||
what service is running.
|
||||
|
||||
- **Location:** `projects/preauth/`
|
||||
- **Framework:** Symfony 7.4 (PHP ≥ 8.4)
|
||||
- **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
|
||||
@@ -38,12 +38,16 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
||||
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. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
|
||||
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`).
|
||||
4. **LoginListener** (priority 66) — Detects login attempts via
|
||||
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`.
|
||||
5. **InterceptListener** (priority 55) — Fallback: if no listener has
|
||||
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.
|
||||
|
||||
@@ -76,8 +80,8 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
||||
|
||||
| Metric | Value |
|
||||
|--------------|--------------------------------|
|
||||
| **Tests** | 222 |
|
||||
| **Assertions** | 469 |
|
||||
| **Tests** | 293 |
|
||||
| **Assertions** | 605 |
|
||||
| **Pass** | 222 (100%) |
|
||||
| **Fail** | 0 |
|
||||
| **Errors** | 0 |
|
||||
@@ -109,12 +113,14 @@ Every class, method, and line in `src/` is covered.
|
||||
| `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 |
|
||||
@@ -122,6 +128,7 @@ Every class, method, and line in `src/` is covered.
|
||||
| `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
|
||||
|
||||
@@ -157,7 +164,7 @@ Every class, method, and line in `src/` is covered.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1 — Public but Rate-Limited Access ✦
|
||||
### 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
|
||||
@@ -169,53 +176,39 @@ bandwidth, forcing it back to fully private. The solution isn't more
|
||||
authentication — it's bandwidth/resource protection for public-facing
|
||||
services.
|
||||
|
||||
**Design:**
|
||||
**Implementation:**
|
||||
|
||||
- New config variables:
|
||||
- `PUBLIC_MODE=false` — Enable public access for specific services
|
||||
- `PUBLIC_RATE_LIMIT=10` — Max requests per minute from a single IP
|
||||
on public paths
|
||||
- `PUBLIC_RATE_WINDOW=60` — Sliding window in seconds
|
||||
- `PUBLIC_BURST=20` — Allow short bursts above the sustained rate
|
||||
- `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: **PublicListener** (priority 95, between AcceptListener
|
||||
and AllowListener):
|
||||
- Checks if the request matches a public path pattern (configured per
|
||||
service via Caddy's `forward_auth` URI or a header like
|
||||
`X-Preauth-Public: true`).
|
||||
- If public mode is enabled for this request, applies aggressive
|
||||
per-IP rate limiting (separate from the login rate limiter).
|
||||
- If within rate limit → `200 OK` (no `Remote-User` header, or a
|
||||
`Remote-User: public` marker).
|
||||
- If over rate limit → `429 Too Many Requests` with `Retry-After`
|
||||
header.
|
||||
- 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).
|
||||
|
||||
- Caddy config would use different `forward_auth` snippets for public
|
||||
vs. protected services:
|
||||
```caddyfile
|
||||
# Protected service — requires TOTP
|
||||
bitwarden.example.com {
|
||||
forward_auth preauth { copy_headers Remote-User }
|
||||
reverse_proxy bitwarden:80
|
||||
}
|
||||
|
||||
# Public but rate-limited service
|
||||
git.example.com {
|
||||
forward_auth preauth/public { copy_headers Remote-User }
|
||||
reverse_proxy gitea:3000
|
||||
}
|
||||
```
|
||||
- New service: **PublicPathMatcher** — Parses path patterns and matches
|
||||
request paths with wildcard support.
|
||||
|
||||
- Consider integration with Caddy's own rate limiting as a second layer
|
||||
of defense (rate limit at the reverse proxy before traffic even hits
|
||||
preauth).
|
||||
- Separate `public_limiter` compound rate limiter (independent from
|
||||
the login attempt rate limiter).
|
||||
|
||||
- [ ] Design public path detection mechanism (URI-based or header-based)
|
||||
- [ ] Implement `PublicListener` with separate rate limiter pool
|
||||
- [ ] Add config variables and defaults
|
||||
- [ ] Update Caddyfile example with public service snippet
|
||||
- [ ] Tests for public mode (within limit, over limit, burst behavior)
|
||||
- [ ] Documentation in README
|
||||
- [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
|
||||
|
||||
@@ -336,7 +329,7 @@ struggle with TOTP apps.
|
||||
command or initial-setup flow to register a passkey).
|
||||
|
||||
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
||||
7.4 and FrankenPHP
|
||||
8.1 and FrankenPHP
|
||||
- [ ] Design passkey registration flow (console command? first-visit
|
||||
setup? separate registration endpoint?)
|
||||
- [ ] Implement challenge generation and storage (extend existing
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# 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 drops privileges via `USER` (Guiding Light
|
||||
§6.4): the image runs as the non-root `app` user (uid/gid 1000) and owns the
|
||||
state paths it needs. Only `/data` is written at runtime — the cache pools
|
||||
behind sessions, backup codes and rate limiting — and `/config` is declared
|
||||
because the base image points Caddy's XDG config dir there. If you pin a
|
||||
different `user:` in your compose file, that user must be able to write to
|
||||
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
|
||||
+1
-3
@@ -9,8 +9,6 @@ docker run --name preauth \
|
||||
-e APP_ENV=dev \
|
||||
-e APP_DEBUG=true \
|
||||
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
|
||||
-e APP_SHARE_DIR=var/share \
|
||||
-e DEFAULT_URI=http://localhost \
|
||||
-v ./var/share:/app/var/share \
|
||||
-e APP_SHARE_DIR=/app/var/share \
|
||||
-p 8000:80 \
|
||||
digitaladapt/preauth:dev
|
||||
|
||||
+20
-19
@@ -4,22 +4,22 @@
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"php": "^8.5",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"bacon/bacon-qr-code": "^3.1.1",
|
||||
"runtime/frankenphp-symfony": "^1.0.0",
|
||||
"spomky-labs/otphp": "^11.4.2",
|
||||
"symfony/cache": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/cache": "8.1.*",
|
||||
"symfony/console": "8.1.*",
|
||||
"symfony/flex": "^2.11",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/yaml": "7.4.*"
|
||||
"symfony/framework-bundle": "8.1.*",
|
||||
"symfony/mime": "8.1.*",
|
||||
"symfony/rate-limiter": "8.1.*",
|
||||
"symfony/runtime": "8.1.*",
|
||||
"symfony/twig-bundle": "8.1.*",
|
||||
"symfony/uid": "8.1.*",
|
||||
"symfony/yaml": "8.1.*",
|
||||
"web-auth/webauthn-lib": "^5.3"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -28,7 +28,10 @@
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"bump-after-update": true,
|
||||
"sort-packages": true
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.5.0"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -66,18 +69,16 @@
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"runtime": {
|
||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
},
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
"require": "8.1.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.95",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^13.2",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*"
|
||||
"symfony/browser-kit": "8.1.*",
|
||||
"symfony/css-selector": "8.1.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1929
-784
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
|
||||
@@ -10,6 +10,10 @@ framework:
|
||||
adapters: cache.adapter.apcu
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.filesystem
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
passkeyRateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
prefix_seed: digitaladapt/preauth
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
@@ -13,3 +13,29 @@ framework:
|
||||
login_limiter:
|
||||
policy: compound
|
||||
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]
|
||||
|
||||
# bounds how many ceremonies one caller can *start*.
|
||||
#
|
||||
# This is a resource guard, NOT part of the login budget: D3 makes the
|
||||
# existing login_limiter the single shared budget for every login method,
|
||||
# and a legitimate `begin` must not consume failure budget. Without this,
|
||||
# an unauthenticated caller could fill the ceremony cache with records.
|
||||
passkey_begin_burst:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%'
|
||||
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds'
|
||||
cache_pool: 'passkeyRateLimitCache'
|
||||
|
||||
@@ -10,3 +10,7 @@ framework:
|
||||
adapters: cache.adapter.array
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.array
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
passkeyRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
|
||||
@@ -15,4 +15,10 @@ twig:
|
||||
teapot_message: '%env(TEAPOT_MESSAGE)%'
|
||||
too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
too_many_message: '%env(TOO_MANY_MESSAGE)%'
|
||||
passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
|
||||
passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
|
||||
debug: '%env(SHELL_VERBOSITY)%'
|
||||
|
||||
# `passkeys` is computed per request by the controller-facing templates via
|
||||
# PasskeyPolicyInterface, never from an env var, so that availability and the
|
||||
# D1/D4 prerequisites cannot drift apart.
|
||||
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__) .
|
||||
declare(strict_types=1);
|
||||
|
||||
if (file_exists(dirname(__DIR__).
|
||||
'/var/cache/prod/App_KernelProdContainer.preload.php')
|
||||
) {
|
||||
require dirname(__DIR__) .
|
||||
require dirname(__DIR__).
|
||||
'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
|
||||
@@ -45,6 +45,32 @@ parameters:
|
||||
env(UPPER_COUNT): 10 # 10 per 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)
|
||||
|
||||
# --- passkey authentication ---
|
||||
# Requires central auth (SUBDOMAIN_REDIRECT=1 + AUTH_SUBDOMAIN) and HTTPS.
|
||||
# Enabling this without central auth makes the container fail at cache warmup
|
||||
# rather than offering a feature that cannot work.
|
||||
env(PASSKEY_ENABLED): '0' # boolean, 1 to offer passkeys on the auth subdomain
|
||||
env(PASSKEY_RP_NAME): '' # blank to use TITLE
|
||||
env(PASSKEY_USER_VERIFICATION): 'required' # required|preferred|discouraged
|
||||
env(PASSKEY_TIMEOUT): '60000' # milliseconds
|
||||
# Extra options, custom labels
|
||||
env(PASSKEY_BUTTON_NAME): 'Sign in with a passkey'
|
||||
env(PASSKEY_REGISTER_NAME): 'Register this device as a passkey'
|
||||
# bounds how many ceremonies one caller can start (resource guard, not the
|
||||
# login budget — see config/packages/rate_limiter.yaml)
|
||||
env(PASSKEY_BEGIN_BURST_COUNT): 30
|
||||
env(PASSKEY_BEGIN_BURST_TIME): 60
|
||||
|
||||
# --- styling options ---
|
||||
env(TITLE): 'Pre-Authentication System'
|
||||
env(BG_COLOR): '#029386' # teal
|
||||
@@ -77,9 +103,24 @@ parameters:
|
||||
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.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
app.title: '%env(TITLE)%'
|
||||
|
||||
app.passkey_enabled: '%env(bool:PASSKEY_ENABLED)%'
|
||||
app.passkey_rp_name: '%env(PASSKEY_RP_NAME)%'
|
||||
app.passkey_user_verification: '%env(PASSKEY_USER_VERIFICATION)%'
|
||||
app.passkey_timeout: '%env(int:PASSKEY_TIMEOUT)%'
|
||||
|
||||
app.passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
|
||||
app.passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -94,3 +135,15 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
# the boot-time passkey configuration check runs during `cache:warmup`, so a
|
||||
# misconfigured deployment fails to start instead of failing in a browser
|
||||
App\Service\PasskeyPolicyInterface: '@App\Service\PasskeyPolicy'
|
||||
App\Service\PasskeyInterface: '@App\Service\PasskeyManager'
|
||||
App\Service\PasskeyCeremonyStoreInterface: '@App\Service\PasskeyCeremonyStore'
|
||||
App\Service\PasskeyCredentialStoreInterface: '@App\Service\PasskeyCredentialStore'
|
||||
App\Service\SessionIssuerInterface: '@App\Service\SessionIssuer'
|
||||
|
||||
# the ceremony factory takes no constructor arguments and holds no state, so
|
||||
# it is built once and shared rather than re-created per ceremony
|
||||
App\Service\PasskeyCeremonyFactory: ~
|
||||
|
||||
@@ -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 = "Optional 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}"] : [],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# PreAuth — Caddyfile / FrankenPHP app config.
|
||||
#
|
||||
# The container serves plain HTTP on :80; TLS is terminated by the upstream
|
||||
# proxy. SERVER_NAME=:80 is set in the Dockerfile.
|
||||
#
|
||||
# This is the config the image ships (/etc/frankenphp/Caddyfile). The
|
||||
# Caddyfile in the repository root is the example for host-side setups.
|
||||
{
|
||||
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}
|
||||
}
|
||||
|
||||
# The admin API is deliberately left at its default: bound to 127.0.0.1
|
||||
# inside the container, where it is the target of the image's
|
||||
# HEALTHCHECK. It is not reachable from outside the container. Do NOT set
|
||||
# `admin off` here without also changing that probe — the app has no 2xx
|
||||
# liveness route to fall back on, because every anonymous request is
|
||||
# answered with the login page and a 401.
|
||||
}
|
||||
|
||||
http:// {
|
||||
root public/
|
||||
rewrite index.php
|
||||
php {
|
||||
root /app/public
|
||||
worker index.php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# PreAuth container entrypoint.
|
||||
#
|
||||
# Responsibilities:
|
||||
# 1. Warm the prod cache with the injected secrets.
|
||||
# 2. Hand off to CMD (FrankenPHP server, or a console override:
|
||||
# `docker exec -it preauth bin/console app:generate-backup-codes`).
|
||||
#
|
||||
# Secrets are env vars injected at runtime, never baked into images (§8.12).
|
||||
# The container has no shell to hand out otherwise — it runs as an unprivileged
|
||||
# user with a nologin shell — so the real boot validation is
|
||||
# `cache:warmup` failing here, which is also what makes it worth doing.
|
||||
|
||||
set -e
|
||||
|
||||
if [ "$APP_ENV" = "prod" ]; then
|
||||
echo "Warming cache..."
|
||||
php bin/console cache:warmup
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
; PreAuth php.ini overrides — merged on top of the FrankenPHP base image
|
||||
; defaults.
|
||||
;
|
||||
; The base image ships no php.ini (only the php.ini-production template), so
|
||||
; the production switches that matter are stated explicitly here rather than
|
||||
; inherited — verified against a real boot: without them the response carries
|
||||
; `X-Powered-By: PHP/8.5.10` and errors would render into the body.
|
||||
;
|
||||
; PreAuth keeps its session state in APCu plus a filesystem cache pool, so the
|
||||
; settings that matter most are the cache ones.
|
||||
|
||||
; Never advertise the interpreter, never print errors to the client. This is
|
||||
; an authentication gateway: a stack trace in a 500 body is an information
|
||||
; leak. Errors go to stderr for the log collector.
|
||||
expose_php = Off
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
error_log = /proc/self/fd/2
|
||||
|
||||
memory_limit = 256M
|
||||
upload_max_filesize = 2M
|
||||
post_max_size = 8M
|
||||
|
||||
; OPcache for the FrankenPHP worker: the image is immutable, so timestamps
|
||||
; never need revalidating. The CLI console also runs the app, hence
|
||||
; enable_cli = 1.
|
||||
opcache.enable = 1
|
||||
opcache.enable_cli = 1
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.memory_consumption = 128
|
||||
opcache.interned_strings_buffer = 16
|
||||
opcache.max_accelerated_files = 20000
|
||||
|
||||
; APCu — nonce cache, rate limiter and session cache all live in it, and the
|
||||
; console needs it too (`bin/console` commands manage cache state).
|
||||
apc.enabled = 1
|
||||
apc.enable_cli = 1
|
||||
apc.shm_size = 64M
|
||||
apc.ttl = 0
|
||||
|
||||
date.timezone = UTC
|
||||
@@ -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 central preauth
|
||||
# set SUBDOMAIN_REDIRECT to true
|
||||
# and AUTH_SUBDOMAIN to match the subdomain you use here
|
||||
auth.example.com {
|
||||
reverse_proxy preauth
|
||||
}
|
||||
@@ -15,6 +15,28 @@
|
||||
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
|
||||
#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page
|
||||
|
||||
# --- passkey authentication ---
|
||||
# Passkeys (Touch ID / Windows Hello / security keys) as an alternative to TOTP.
|
||||
#
|
||||
# REQUIRES central authentication (SUBDOMAIN_REDIRECT=true plus AUTH_SUBDOMAIN)
|
||||
# and HTTPS. Passkeys are bound to a relying party that spans the base domain,
|
||||
# which only exists when central auth is configured; and browsers refuse to run
|
||||
# a ceremony over plain HTTP.
|
||||
#
|
||||
# Enabling this without central auth is a hard error: the container fails at
|
||||
# start-up (cache:warmup) rather than offering a passkey button that cannot work.
|
||||
#
|
||||
# There is deliberately no option to allow an http:// origin, and none to relax
|
||||
# the requirement for local development. See the README for the local TLS setup.
|
||||
#PASSKEY_ENABLED=false # default disabled, boolean
|
||||
#PASSKEY_RP_NAME='' # blank to use TITLE
|
||||
#PASSKEY_USER_VERIFICATION='required' # required | preferred | discouraged
|
||||
#PASSKEY_TIMEOUT=60000 # ceremony timeout in milliseconds
|
||||
#PASSKEY_BUTTON_NAME='Sign in with a passkey'
|
||||
#PASSKEY_REGISTER_NAME='Register this device as a passkey'
|
||||
#PASSKEY_BEGIN_BURST_COUNT=30 # ceremonies one caller may start per window
|
||||
#PASSKEY_BEGIN_BURST_TIME=60 # window for the above, in seconds
|
||||
|
||||
# --- extra options ---
|
||||
|
||||
# how long do we allow *ALL* traffic from an ip address after successful login
|
||||
@@ -24,6 +46,14 @@
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
#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)
|
||||
@@ -43,6 +73,18 @@
|
||||
#UPPER_COUNT=10 # 10 per 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 ---
|
||||
|
||||
#TITLE='Pre-Authentication System'
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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
|
||||
#
|
||||
# Passkeys (PASSKEY_ENABLED) require this block AND HTTPS: the ceremony runs
|
||||
# here and the credential is scoped to the base domain. Caddy provisions a
|
||||
# certificate automatically for a real hostname, so nothing extra is needed in
|
||||
# production. This block is also deliberately NOT behind forward_auth — the
|
||||
# browser talks to it directly during a ceremony.
|
||||
auth.example.com {
|
||||
reverse_proxy preauth
|
||||
}
|
||||
|
||||
# --- local development with passkeys ---
|
||||
# Browsers only allow a WebAuthn ceremony over HTTPS, and preauth does not offer
|
||||
# an exemption for http://localhost (that would be a way to run passkeys
|
||||
# insecurely in production). So to exercise passkeys locally, give yourself a
|
||||
# real hostname and a locally-trusted certificate:
|
||||
#
|
||||
# 1. Point the names at your machine:
|
||||
# # /etc/hosts
|
||||
# 127.0.0.1 auth.preauthtest.local app.preauthtest.local
|
||||
# 2. Trust a certificate for them (mkcert installs a local CA):
|
||||
# mkcert auth.preauthtest.local app.preauthtest.local
|
||||
#
|
||||
# 3. In preauth's .env:
|
||||
# SUBDOMAIN_REDIRECT=true
|
||||
# AUTH_SUBDOMAIN=auth.preauthtest.local
|
||||
# PASSKEY_ENABLED=true
|
||||
#
|
||||
# 4. Terminate TLS here and proxy to the container:
|
||||
#
|
||||
# auth.preauthtest.local, "*.preauthtest.local" {
|
||||
# tls /path/to/auth.preauthtest.local+1.pem /path/to/auth.preauthtest.local+1-key.pem
|
||||
# reverse_proxy preauth
|
||||
# }
|
||||
#
|
||||
# Note "localhost" itself cannot be used: it has no base domain, so central
|
||||
# auth cannot be configured and passkeys stay disabled.
|
||||
|
||||
# --- 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,8 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# rename "example.env" to ".env", edit as needed
|
||||
# copy ".env.example" to ".env", edit as needed, and put APP_SECRET
|
||||
# in it (any long random string).
|
||||
# strongly recommend setting TOTP_URI, if not provided the app
|
||||
# will generate one for you, please copy it into your .env file
|
||||
- .env
|
||||
@@ -9,8 +10,10 @@ services:
|
||||
- 80
|
||||
image: digitaladapt/preauth:latest
|
||||
restart: unless-stopped
|
||||
# if you wish to set the user, you must make sure that the user
|
||||
# can write to /config and /data within the container
|
||||
# The image runs as the non-root `app` user (uid/gid 1000) and creates
|
||||
# its state directories owned by that user, so a named volume inherits
|
||||
# the right ownership on first start — no `user:` override is needed.
|
||||
# If you pin one anyway, it must be able to write /config and /data.
|
||||
#user: <uid>:<gid>
|
||||
volumes:
|
||||
- preauth-config:/config
|
||||
@@ -19,4 +22,3 @@ services:
|
||||
volumes:
|
||||
preauth-config:
|
||||
preauth-data:
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
# Plan — Passkey Authentication for the Dedicated Auth Subdomain
|
||||
|
||||
**Status:** 📋 Draft for review — no application code written yet
|
||||
**Target:** next minor release (version to confirm — see Q1.1)
|
||||
**Prepared:** 2026-09-26 against `main` @ `0458d9b`
|
||||
**Revised:** 2026-09-27 — review round 2 (D4/D5, §2.3)
|
||||
**Verified against:** `web-auth/webauthn-lib` 5.3.9 on PHP 8.5.11 / Symfony 8.1
|
||||
|
||||
---
|
||||
|
||||
## 0. Decisions locked in (from review feedback)
|
||||
|
||||
Five clarifications from the project owner reshape this plan. They are
|
||||
**decisions**, not options, and everything below follows from them.
|
||||
|
||||
| # | Decision | Consequence |
|
||||
|---|---|---|
|
||||
| **D1** | **Central auth (dedicated auth subdomain) is a hard prerequisite** for passkeys. Without it, a passkey would collide with / confuse the passkey for the protected service itself. | Passkeys are simply **not offered** unless `SUBDOMAIN_REDIRECT=true` *and* `AUTH_SUBDOMAIN` is set. The RP ID is *always* `authBase()`. There is no single-host passkey mode, no per-service RP ID, and no ambiguity to document away. |
|
||||
| **D2** | **Registration happens in the browser**, initiated by a simple "register passkey" checkbox on the login form — not a CLI command. | Registration reuses the existing login form, nonce/CSRF machinery and TOTP verification. This also **answers the identity question**: the identity is the `Session ID` field the user already types, exactly as with TOTP. |
|
||||
| **D3** | **Rate limiting covers all forms of login.** If an IP is rate-limited, that includes passkeys. | Passkey ceremonies run **behind** the existing `RejectListener` gate and consume the **same** login limiter budget on failure. No way to sidestep a lockout by switching methods. |
|
||||
| **D4** | **HTTPS is required — in development too.** No "secured relying party" exemption is supported, deprecated or otherwise. | The derived allowed-origin is *always* `https://…`, built from config and never from the request. The `PASSKEY_ALLOWED_ORIGINS` escape hatch from the first draft is **deleted**. Local development uses real TLS (§4.2). HTTPS becomes part of the boot-time assertion alongside D1 (Q3.1). |
|
||||
| **D5** | **Attestation is `none`, deliberately.** The "set a real value instead" instinct was tested and is wrong *here* — every alternative is either broken or bypassable (§2.3). | Records are anonymous: zero AAGUID, `EmptyTrustPath`. No metadata service, no `web-token/jwt-library` dependency, no download of the FIDO BLOB. `SECURITY.md` states the reasoning and the conditions that would change it. |
|
||||
|
||||
Consequences worth stating plainly:
|
||||
|
||||
- The separate `passkey_limiter`, `PASSKEY_ENABLED=false` default, and the whole
|
||||
"should we support single-host passkeys?" question from the first draft are
|
||||
**gone**. D1 removes the configuration matrix; D3 removes the second limiter.
|
||||
- The first draft's §7 (CLI registration, enrolment tokens, `--identity`) is
|
||||
**deleted**. D2 replaces it with a checkbox.
|
||||
- D4 keeps D1 exactly as strict — HTTPS is an **additional** requirement, never a
|
||||
relaxation. D5 is the one place where "use the stricter-sounding option" loses,
|
||||
and §2.3 shows the measurements behind that.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let a user authenticate with a passkey (Touch ID, Windows Hello, Android
|
||||
biometrics, hardware security key) instead of typing a 6-digit TOTP code —
|
||||
served from the dedicated auth subdomain, where one passkey unlocks every
|
||||
service on the base domain.
|
||||
|
||||
TOTP and backup codes remain and are never removed (Q2.1).
|
||||
|
||||
---
|
||||
|
||||
## 2. What the spike proved
|
||||
|
||||
The first draft contained claims that had not been executed. They now have been:
|
||||
the library was installed, and a script performed a **complete registration and
|
||||
assertion ceremony with real ES256 cryptography**, plus the negative cases.
|
||||
|
||||
**Environment:** PHP 8.5.11, Composer 2.10.3, `web-auth/webauthn-lib` **5.3.9**.
|
||||
Baseline suite green before and after install: **313 tests / 738 assertions**.
|
||||
`composer audit`: *"No security vulnerability advisories found."*
|
||||
`.ci/conformance.sh --profile=auth-gateway`: **all 35 checks pass**.
|
||||
|
||||
### 2.1 Confirmed correct
|
||||
|
||||
| Claim | Result |
|
||||
|---|---|
|
||||
| Installs on PHP 8.5 + Symfony 8.1 with no conflicts | ✅ resolves to 5.3.9; `lint:container`, `lint:yaml`, `lint:twig` all pass |
|
||||
| Only needs `ext-json` + `ext-openssl` | ✅ (`ext-openssl` is present in every official PHP image, so `Dockerfile` needs **no** extension work) |
|
||||
| No Symfony Security bundle, no Doctrine, no bundler | ✅ `CeremonyStepManagerFactory` + `Authenticator*ResponseValidator::create()` are pure; the `webauthn-symfony-bundle` is unnecessary |
|
||||
| rpId `example.com` admits an origin on `auth.example.com` | ✅ assertion ACCEPTED |
|
||||
| …and also on `app.example.com` with the *same* credential | ✅ ACCEPTED — one passkey across all subdomains, as designed |
|
||||
| Credential is cryptographically bound to the rpId | ✅ a forged `rpIdHash` is rejected: *"rpId hash mismatch"* |
|
||||
| An origin outside the allow-list is rejected | ✅ *"Invalid origin. Not in the list of allowed origins."* |
|
||||
| Wrong origin / wrong challenge rejected | ✅ `AuthenticatorResponseVerificationException` in both cases |
|
||||
| CSP `publickey-credentials-*` do **not** inherit `default-src` | ✅ confirmed in the CSP3 spec (§6.8.3 fallback list omits WebAuthn directives) — the CSP change in §5.5 is required |
|
||||
| base64url credential IDs survive `makeCacheKey()` without collision | ✅ 200 000 random 32-byte IDs, zero collisions |
|
||||
| `attestation: 'none'` yields an anonymous record | ✅ `attestationType="none"`, zero AAGUID, `EmptyTrustPath` (§2.3, config A) |
|
||||
| Origin scheme can never be inferred from the request | ✅ the scheme comes from the single allow-list string; an `https://` entry rejects an `http://` origin (§4.2) |
|
||||
| `localhost` cannot accidentally enable passkeys | ✅ `baseDomain('localhost') === null` ⇒ `authBase() === null` ⇒ D1 unsatisfied (§4.2) |
|
||||
| `auth.localhost` *does* satisfy D1 | ✅ `authBase() === 'auth.localhost'` (§4.2) |
|
||||
| No MDS ⇒ no `web-token/jwt-library` needed | ✅ `FidoAllianceCompliantMetadataService` throws unless the JWT library is present; not installed, and MDS is not used (D5) |
|
||||
|
||||
### 2.2 Corrections to the first draft (things that would have bitten us)
|
||||
|
||||
| # | First draft said | Reality | Impact |
|
||||
|---|---|---|---|
|
||||
| **C1** | "`CredentialRecord` is JSON-serializable, so it fits the no-database constraint." | It is a **plain class**, not `JsonSerializable`. Persistence goes through `WebauthnSerializerFactory` (a Symfony Serializer with ~25 custom normalizers). | The store must use that factory. `symfony/serializer`, `property-info`, `property-access` arrive as transitive deps — no extra work, but the store can't just `json_encode()`. |
|
||||
| **C2** | (unstated) treat option objects as plain JSON | `json_encode($creationOptions)` **throws** `JsonException: Malformed UTF-8` — the challenge is raw binary. Options **must** be serialized by the same factory, which base64url-encodes binary fields. | Both the `begin` payload and the stored record go through one `SerializerInterface`. Caught immediately by the spike; would otherwise have been a runtime 500 on first test. |
|
||||
| **C3** | "the package carries 3 published advisories" | `composer audit` against 5.3.9 reports **none**. | No remediation work; record the clean audit in the CHANGELOG. |
|
||||
| **C4** | counter handling not mentioned | Counter replay raises `CounterException`, which can **mask** the real reason a verification failed. | Test helper must use an incrementing counter per ceremony, or negative tests give false passes (this actually happened during the spike and had to be fixed). |
|
||||
| **C5** | "keep the library default" for the counter | The default requires a strictly *increasing* counter. Measured: stored `0`, reported `0` → `CounterException`. A synchronised passkey reports `0` forever, so **every** such credential fails on its **first** login — and only on real hardware, since a test helper that increments never reproduces it. | `PasskeyCounterChecker` accepts `>=` and rejects strictly backwards. Pinned by `PasskeyCounterCheckerTest`, including a test asserting the library default still rejects `0`/`0` so this reasoning is re-checked if the dependency is upgraded. |
|
||||
| **C5** | separate `passkey_limiter` + `publicRateLimitCache`-style pool | Decision D3 makes it redundant for the *login* budget. | Drop it. One small limiter remains, for a different purpose (§5.4). |
|
||||
|
||||
### 2.3 Attestation: why `none`, measured rather than assumed
|
||||
|
||||
The review asked the right question — *"is there any downside to `null`, and if it
|
||||
needs a note in `SECURITY.md`, shouldn't we set a real value?"* — so it was
|
||||
tested instead of argued. Seven configurations were run against 5.3.9
|
||||
(`spike_attestation.php`, `spike_att2.php`). Results are summarised, not
|
||||
predicted:
|
||||
|
||||
| # | Configuration | Outcome | What the server actually learns |
|
||||
|---|---|---|---|
|
||||
| **A** | `attestation=none`, `fmt=none` | ✅ accepted | `attestationType="none"`, aaguid all-zero, `EmptyTrustPath`. **Nothing.** |
|
||||
| **B** | `attestation=direct`, `fmt=packed` **self**, no MDS | ✅ accepted | A real AAGUID string — but no metadata to interpret it against, so it is untrusted and uninterpretable. |
|
||||
| **C** | `attestation=direct`, `fmt=packed` **basic** (`x5c` cert), no MDS | ❌ **rejected** | *"The Metadata Statement Repository is mandatory when requesting attestation objects."* |
|
||||
| **C2** | …same, MDS enabled, metadata **empty** | ❌ **rejected** | *"The Metadata Statement for the AAGUID … is missing."* This is the real cost of MDS: **every** authenticator must be known in advance. |
|
||||
| **C3** | MDS enabled, but the client sends a **zero** AAGUID | ✅ **accepted** | *"Null AAGUID detected. Skipping metadata verification."* — **MDS is bypassable by design.** |
|
||||
| **C4** | MDS enabled, `fmt=packed` **self** attestation, AAGUID **unknown** to MDS | ✅ **accepted** | `processSelfAttestation()` returns early when the AAGUID has no metadata entry, so **self attestation is never refused by MDS** — even a *known-unknown* device passes. |
|
||||
| **D** | `attestation=direct` requested, client sends `fmt=none` | ✅ **accepted** | Asking for `direct` does **not** compel compliance — conveyance is a *preference*, so the RP cannot force it. |
|
||||
|
||||
Three conclusions follow, and they are the reason D5 is `none`:
|
||||
|
||||
1. **Attestation cannot be *enforced*, only *requested*.** Configuration D shows a
|
||||
client answering a `direct` request with `none` and being accepted regardless.
|
||||
Any policy that depends on the client cooperating is not a security control.
|
||||
2. **MDS is bypassable two different ways.** C3 is the decisive row: a zero AAGUID
|
||||
short-circuits metadata verification *before* the repository is ever consulted.
|
||||
Since passkeys from Apple/Google/Windows deliberately send zero AAGUIDs, an
|
||||
attacker can present the same shape and skip MDS entirely — while legitimate
|
||||
users are unaffected. C4 closes the remaining door on the same conclusion: with
|
||||
`fmt=packed` **self** attestation (the format a software/platform authenticator
|
||||
can produce without any vendor certificate), `processSelfAttestation()` returns
|
||||
early when the AAGUID has no metadata entry, so even a device that is *unknown*
|
||||
to MDS is accepted. Taken together: an MDS deployment refuses honest
|
||||
certificate-bearing authenticators that postdate its cached BLOB (C2), while
|
||||
still admitting the bypassable and self-attested cases. That is the worst
|
||||
combination — friction for legitimate users, no assurance gained.
|
||||
3. **`none` is not a weaker version of the same check — it is the honest
|
||||
description of reality.** The property that actually protects users is that the
|
||||
credential is cryptographically bound to the RP ID and origin (§2.1), which
|
||||
holds identically in every row above. Attestation answers *"which device model
|
||||
is this?"* — a question this project does not need to answer, because it does
|
||||
not run a device-allow-list policy.
|
||||
|
||||
**What a real value would actually cost**, for the record: `direct` requires the
|
||||
metadata repository (C) — verified as a hard failure, not a warning — which means
|
||||
`web-token/jwt-library`, `symfony/http-client`, a periodic download of the FIDO
|
||||
Alliance BLOB, certificate-chain validation on every registration, and a new
|
||||
failure mode where a legitimate new phone is **rejected at enrolment** because its
|
||||
AAGUID postdates the cached BLOB. All of that to gain a bypassable signal.
|
||||
|
||||
> **Where to revisit this.** D5 is the right call *for a self-hosted
|
||||
gateway that does not distinguish devices*. It stops being the right call if the
|
||||
project ever wants to (a) refuse specific authenticator models, or (b) prove
|
||||
enrolment happened on hardware rather than a synced passkey. Both would require
|
||||
MDS **plus** a decision to reject zero AAGUIDs — which is why the reasoning is
|
||||
recorded in `SECURITY.md` rather than left implicit in a constant.
|
||||
|
||||
---
|
||||
|
||||
## 3. The flow, end to end
|
||||
|
||||
### 3.1 First-time setup (D2 — in the browser)
|
||||
|
||||
```
|
||||
Browser → https://app.example.com/dashboard
|
||||
forward_auth → preauth (host=app.example.com) → InterceptListener
|
||||
matchesAuth() && host !== auth subdomain
|
||||
⇒ 303 https://auth.example.com/?return=https%3A%2F%2Fapp.example.com%2Fdashboard
|
||||
|
||||
Browser → https://auth.example.com/?return=…
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Pre-Authentication System │
|
||||
│ │
|
||||
│ Session ID: [ lyra ] │
|
||||
│ Authentication Token:[ 123456 ] │
|
||||
│ [x] Register this device as a passkey ← new │
|
||||
│ [ Submit ] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The checkbox only appears when passkeys are available (D1 satisfied) — see §4.
|
||||
|
||||
**Submission with the box ticked** becomes a three-step ceremony:
|
||||
|
||||
```
|
||||
1. POST / (auth host), form fields username+totp+nonce+register=passkey
|
||||
LoginListener → LoginManager verifies TOTP/backup code + nonce [unchanged]
|
||||
↳ instead of issuing a session, it starts a REGISTRATION ceremony:
|
||||
stores passkey_reg_<cid> → { challenge, identity, userHandle } (TTL 300s)
|
||||
⇐ 200 JSON { register: { publicKey: <options>, ceremonyId: <cid> } }
|
||||
|
||||
2. Browser: navigator.credentials.create({ publicKey: options })
|
||||
→ user approves with Touch ID / Windows Hello / security key
|
||||
|
||||
3. POST / (auth host) X-Preauth-Passkey: register-finish
|
||||
body: { ceremonyId, credential: <attestation JSON> }
|
||||
PasskeyListener → PasskeyManager verifies attestation against the stored
|
||||
challenge; stores the credential under the identity from the record
|
||||
⇐ 303 Location: <return url> + Set-Cookie: __Http-Domain-Preauth=…
|
||||
```
|
||||
|
||||
The TOTP check in step 1 is what authorises registration. There is no separate
|
||||
enrolment token, no CLI, and **no way to create a credential without already
|
||||
holding a valid TOTP code** — which is exactly the security property the CLI
|
||||
design was reaching for.
|
||||
|
||||
> **Implementation note — where the hand-off actually goes.**
|
||||
> `LoginListener::onKernelRequest()` is a straight chain: it builds a `Payload`,
|
||||
> calls `$this->loginManager->checkToken(...)`, and on a non-null response it
|
||||
> does `$event->setResponse($response); return;` — on `null` it immediately
|
||||
> scores a failure and consumes a rate-limit token. There is no "authenticated
|
||||
> but do not issue a session" branch to hook.
|
||||
>
|
||||
> So the clean split is: `LoginListener` detects `register=passkey` in the POST
|
||||
> body and marks the **`Payload`** with the intent; `LoginManager::checkToken()`
|
||||
> verifies TOTP/backup-code **and the nonce** exactly as it does today, and only
|
||||
> then, if the intent is set, delegates to the registration ceremony instead of
|
||||
> issuing a session. That keeps the nonce/CSRF guarantee in the one place that
|
||||
> already enforces it — the alternative (starting a ceremony from the listener
|
||||
> before `checkToken` runs) would move nonce validation and would need care to
|
||||
> avoid double-spending it.
|
||||
|
||||
### 3.2 Everyday login (assertion)
|
||||
|
||||
```
|
||||
Browser → https://auth.example.com/?return=…
|
||||
[ 🔑 Sign in with a passkey ] ← button, one tap
|
||||
─────────── or use a code ───────────
|
||||
Session ID: [ … ] Token: [ … ] [ Submit ]
|
||||
|
||||
Passkey button:
|
||||
1. POST / X-Preauth-Passkey: login-begin
|
||||
⇐ 200 JSON { publicKey: { challenge, rpId, allowCredentials[], … },
|
||||
ceremonyId }
|
||||
2. navigator.credentials.get({ publicKey })
|
||||
3. POST / X-Preauth-Passkey: login-finish body: { ceremonyId, credential }
|
||||
PasskeyManager verifies the assertion against the stored record
|
||||
⇐ 303 + cookie, or 401 JSON { message, nonce }
|
||||
```
|
||||
|
||||
No username is typed: the credential carries its own identity (stored at
|
||||
registration). `allowCredentials` lists all registered credentials, so the OS
|
||||
picker decides which device to use.
|
||||
|
||||
### 3.3 Listener priority (D3)
|
||||
|
||||
```
|
||||
Priority Listener Action
|
||||
──────── ───────────────────── ─────────────────────────────────────────
|
||||
99 AcceptListener Valid cookie → 200 OK
|
||||
88 AllowListener Valid IP session → 200 OK
|
||||
84 PublicAccessListener Public path + rate limit → 200/429
|
||||
77 RejectListener LOGIN RATE-LIMIT GATE → 418/429
|
||||
70 PasskeyListener (new) WebAuthn ceremony → JSON
|
||||
66 LoginListener TOTP / backup-code login
|
||||
55 InterceptListener Fallback → redirect or login page
|
||||
```
|
||||
|
||||
**Why 70 — after `RejectListener` and before `LoginListener`:**
|
||||
|
||||
- **After 77 (D3):** a rate-limited IP is refused *before* any ceremony can
|
||||
start. Passkeys cannot be used to sidestep a lockout. This is the whole point
|
||||
of the reviewer's third clarification, and it reverses the first draft.
|
||||
- **Before 66:** essential. `LoginListener` treats *any* POST to the auth
|
||||
subdomain as a login attempt (`$domainManager->getAuthSubdomain() === $host`).
|
||||
A ceremony `finish` POST has no `username`/`totp`, so `Payload::load()` returns
|
||||
`null` and the request would be scored as a **failed login and burn a rate-limit
|
||||
token**. `PasskeyListener` must claim the request first.
|
||||
|
||||
`PasskeyListener` sets a response for *every* request carrying its header —
|
||||
including malformed ones — so control never falls through to
|
||||
`InterceptListener`, which would render HTML to a `fetch()` caller. (Q3.2)
|
||||
|
||||
---
|
||||
|
||||
## 4. Availability rule (D1 + D4)
|
||||
|
||||
Passkeys are offered **only** when all of these hold:
|
||||
|
||||
```php
|
||||
$passkeysAvailable =
|
||||
$config->passkeyEnabled() // PASSKEY_ENABLED=1 (default 0)
|
||||
&& null !== $domainManager->authBase() // SUBDOMAIN_REDIRECT=1 && AUTH_SUBDOMAIN set
|
||||
&& $domainManager->getAuthSubdomain() === $request->getHost(); // we are ON the auth host
|
||||
```
|
||||
|
||||
…and, separately, the **deployment** must satisfy HTTPS (D4). That is checked
|
||||
once at boot rather than per request, because "is this request HTTPS" is not the
|
||||
right question behind a TLS-terminating proxy — see §4.2.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **RP ID is always `authBase()`** — never the request host, never configurable
|
||||
per-service. `example.com` for `auth.example.com`.
|
||||
- **Allowed origins is exactly one entry**: `https://{AUTH_SUBDOMAIN}`, built
|
||||
from config. Because `InterceptListener` funnels every unauthenticated user to
|
||||
the auth host, no other origin ever needs to run a ceremony. This is the
|
||||
tightest configuration that still delivers "one passkey, every service" (§2.1).
|
||||
- On a protected host, `InterceptListener` already redirects before rendering a
|
||||
login page, so the checkbox is naturally absent there.
|
||||
- If someone sets `PASSKEY_ENABLED=1` without central auth, the app must
|
||||
**fail loudly at boot**, not silently ignore it (Q3.1). A silent ignore is how
|
||||
you get "I enrolled a passkey and now I can't log in" support tickets.
|
||||
|
||||
`rpName` for the OS prompt defaults to `TITLE`.
|
||||
|
||||
### 4.1 Identity and userHandle
|
||||
|
||||
The identity is the `Session ID` the user typed — the same value TOTP uses, so
|
||||
`Remote-User` modes (`session`/`static`/`mapped`) keep working unchanged.
|
||||
|
||||
- `userHandle` = `hash('sha256', $identity, true)` (32 raw bytes). Fixed length,
|
||||
stable per identity, and does not leak the label into the authenticator.
|
||||
- On assertion, the identity is read from the **stored credential record**, not
|
||||
from the client-returned `userHandle`. The client's copy is never trusted.
|
||||
- Because registration is gated behind a successful TOTP login, one identity
|
||||
cannot be registered by someone who does not already hold the TOTP secret.
|
||||
|
||||
### 4.2 HTTPS (D4) — enforced, not exempted
|
||||
|
||||
D4 removes the exemption system entirely: **there is no code path that accepts an
|
||||
`http://` origin for passkeys**, and no configuration that re-enables one. The
|
||||
library's `setSecuredRelyingPartyId()` (deprecated since 5.2, confirmed in
|
||||
`CeremonyStepManagerFactory`) is **never called**.
|
||||
|
||||
Measured behaviour of the origin check (`spike_origin.php`), all with rpId
|
||||
`example.com`:
|
||||
|
||||
| Allowed origins | Client origin | Result |
|
||||
|---|---|---|
|
||||
| `https://auth.example.com` | `https://auth.example.com` | ✅ accepted |
|
||||
| `http://localhost:8000` | `http://localhost:8000` | ✅ accepted — **only** because `http://` was explicitly allow-listed |
|
||||
| `localhost:8000` (host-only) | `http://localhost:8000` | ❌ rejected |
|
||||
| `https://auth.example.com` | `http://auth.example.com` | ❌ rejected |
|
||||
| `https://example.com` +subdomains | `https://app.example.com` | ✅ accepted |
|
||||
| `https://example.com` +subdomains | `http://app.example.com` | ❌ rejected |
|
||||
| `https://example.com` (no subdomains) | `https://app.example.com` | ❌ rejected — *"Subdomains are not allowed."* |
|
||||
|
||||
The scheme is therefore never inferred from the request; it comes from the single
|
||||
`https://{AUTH_SUBDOMAIN}` string. Note the second row — the library *will* accept
|
||||
plain HTTP **if the operator writes it into the allow-list**, which is precisely
|
||||
the hole D4 closes by deleting `PASSKEY_ALLOWED_ORIGINS`.
|
||||
|
||||
**Two gotchas this creates for local development**, both verified against
|
||||
`DomainManager` (`spike_devhost.php`):
|
||||
|
||||
1. `baseDomain('localhost')` returns **`null`** by design, so `authBase()` is also
|
||||
`null` and **`localhost` can never satisfy D1** — passkeys stay off there no
|
||||
matter what. `auth.localhost`, by contrast, resolves to `authBase()` of
|
||||
`auth.localhost` and *does* satisfy D1.
|
||||
2. Because the origin must be `https://`, dev cannot simply point a browser at
|
||||
`http://auth.localhost`. The supported dev workflow is therefore **a local TLS
|
||||
certificate**, not an exemption:
|
||||
|
||||
```
|
||||
# Development with real TLS — the only supported way to exercise passkeys
|
||||
AUTH_SUBDOMAIN=auth.preauthtest.local
|
||||
SUBDOMAIN_REDIRECT=true
|
||||
PASSKEY_ENABLED=1
|
||||
# /etc/hosts → 127.0.0.1 auth.preauthtest.local app.preauthtest.local
|
||||
# mkcert auth.preauthtest.local app.preauthtest.local
|
||||
# Caddy terminates TLS with the mkcert cert and reverse_proxies to :80
|
||||
```
|
||||
|
||||
This is a **documentation and CI** change, not an application-code change: the app
|
||||
already sits behind a TLS-terminating proxy in production (`docker/Caddyfile`
|
||||
serves plain HTTP on `:80`, `trusted_headers` includes `x-forwarded-proto`), so
|
||||
D4 adds no runtime branching. `docs/examples/Caddyfile` gains a TLS-enabled
|
||||
development block, and the functional tests (§7.2) drive the HTTPS origin directly
|
||||
because they build `clientDataJSON` by hand — no real TLS needed in the suite.
|
||||
|
||||
> **Not `localhost`.** Because D4 forbids `http://`, the classic
|
||||
> `http://localhost` dev story simply does not apply to passkeys. `localhost` is
|
||||
> treated as *"passkeys unavailable"*, which keeps D1 intact instead of carving
|
||||
> out an exception that would then need its own tests.
|
||||
|
||||
---
|
||||
|
||||
## 5. Design detail
|
||||
|
||||
### 5.1 `PasskeyManager` (new service)
|
||||
|
||||
Owns both ceremonies. Library types stay inside this class so a future v6 rename
|
||||
touches one file.
|
||||
|
||||
```php
|
||||
final readonly class PasskeyManager implements PasskeyInterface
|
||||
{
|
||||
public function beginLogin(Request $request): array; // → options + ceremonyId
|
||||
public function finishLogin(array $body, Request $request): ?Response;
|
||||
public function beginRegistration(string $identity, Request $request): array;
|
||||
public function finishRegistration(array $body, Request $request): ?Response;
|
||||
}
|
||||
```
|
||||
|
||||
Built on the verified recipe:
|
||||
|
||||
```php
|
||||
$attestationManager = AttestationStatementSupportManager::create();
|
||||
$attestationManager->add(NoneAttestationStatementSupport::create()); // D5 (§2.3)
|
||||
|
||||
$csm = new CeremonyStepManagerFactory();
|
||||
$csm->setAllowedOrigins(["https://{$domainManager->getAuthSubdomain()}"]);
|
||||
$csm->setAlgorithmManager(AlgorithmManager::create()->add(ES256::create()));
|
||||
$csm->setAttestationStatementSupportManager($attestationManager);
|
||||
|
||||
$attestationValidator = AuthenticatorAttestationResponseValidator::create($csm->creationCeremony());
|
||||
$assertionValidator = AuthenticatorAssertionResponseValidator::create($csm->requestCeremony());
|
||||
$serializer = (new WebauthnSerializerFactory($attestationManager))->create();
|
||||
```
|
||||
|
||||
- `setSecuredRelyingPartyId()` is **deprecated in 5.2** (confirmed in the source,
|
||||
`@deprecated since 5.2.0 … Use setAllowedOrigins instead`) — **never called
|
||||
(D4)**. Development uses real TLS, not an exemption (§4.2).
|
||||
- `attestation: 'none'` for registration **(D5, §2.3)**; no metadata service, so
|
||||
neither `web-token/jwt-library` nor `symfony/http-client` is needed — the
|
||||
latter confirmed absent from the current install, so reaching for MDS would add
|
||||
a second new dependency, not just code.
|
||||
- Counter: **replaced the library default** — see §2.2 C5. `ThrowExceptionIfInvalid`
|
||||
requires a *strictly increasing* counter, which rejects a synchronised passkey
|
||||
on its first login; `PasskeyCounterChecker` accepts equal-or-greater and still
|
||||
rejects moves backwards. Clone detection is not relied upon. Test helpers must
|
||||
still increment (C4).
|
||||
|
||||
### 5.2 Ceremony state
|
||||
|
||||
Stored in the **`nonceCache`** pool (already APCu, already excluded from
|
||||
`kernel.reset` in `TestKernel`, already short-lived, and — correctly — *not*
|
||||
persisted to disk, so ceremonies do not survive a restart):
|
||||
|
||||
```
|
||||
passkey_cer_<ceremonyId> → { type: 'login'|'register',
|
||||
challenge: <base64url>,
|
||||
identity?: string, // register only
|
||||
userHandle?: string, // register only
|
||||
returnUrl?: string,
|
||||
createdAt: <iso8601> } TTL 300s
|
||||
```
|
||||
|
||||
- `ceremonyId` is a fresh 15-byte base64url string, issued to the client. The
|
||||
client's copy of the challenge is **never** trusted; the server-side record is
|
||||
authoritative.
|
||||
- **Single-use**: deleted on read at `finish`, before verification, so a failed
|
||||
or replayed assertion cannot be retried against the same challenge.
|
||||
- TTL 300 s (5 min) rather than the nonce's 120 s, because a user has to
|
||||
interact with a biometric prompt.
|
||||
|
||||
### 5.3 Credential store (new service)
|
||||
|
||||
```php
|
||||
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
|
||||
{
|
||||
public function all(): array; // for allowCredentials
|
||||
public function find(string $credentialId): ?array; // record + metadata
|
||||
public function save(CredentialRecord $record, string $identity, string $label): void;
|
||||
public function updateCounter(CredentialRecord $record): void;
|
||||
public function remove(string $credentialId): bool;
|
||||
public function count(): int;
|
||||
}
|
||||
```
|
||||
|
||||
Cache layout in **`sessionCache`** (the persisted pool):
|
||||
|
||||
```
|
||||
passkey_cred_<makeCacheKey(credentialId)> → { record: <serialized CredentialRecord>,
|
||||
identity: string,
|
||||
label: string,
|
||||
createdAt: iso8601,
|
||||
lastUsedAt: iso8601|null }
|
||||
passkey_index → { <credentialId>: {identity, label, createdAt}, … }
|
||||
```
|
||||
|
||||
> ⚠️ **Verified gotcha.** `PersistCache::persist()` only flushes keys recorded by
|
||||
> a `MonitorCacheKeys` instance, and it watches `sessionCache`. `LoginManager`
|
||||
> and `BackupCodeManager` therefore each wrap their injected pool:
|
||||
> `$this->sessionCache = new MonitorCacheKeys($sessionCache);`.
|
||||
> `PasskeyCredentialStore` **must do the same**, or credentials live only in APCu
|
||||
> and vanish on the next container restart — a bug that would surface only after
|
||||
> a redeploy. Add an explicit test asserting the write is visible in the
|
||||
> underlying persistent pool.
|
||||
|
||||
`passkey_index` avoids scanning the whole key space for the login page's
|
||||
`allowCredentials` list.
|
||||
|
||||
### 5.4 Rate limiting (D3)
|
||||
|
||||
**No new limiter for the login budget.** Instead:
|
||||
|
||||
| Event | Limiter behaviour |
|
||||
|---|---|
|
||||
| Any request to the auth host, incl. `*-begin` | `RejectListener` (77) gates first — a blocked IP never reaches `PasskeyListener` |
|
||||
| `login-finish` **failure** | consumes `login_limiter` (1 token) — identical to a wrong TOTP code |
|
||||
| `register-finish` **failure** | consumes `login_limiter` |
|
||||
| successful ceremony | consumes nothing |
|
||||
| `*-begin` | not consumed (a legitimate login must not burn failure budget) |
|
||||
|
||||
This satisfies "if the login attempt has been rate limited, that would include
|
||||
all forms of login": after 10 failures the IP is blocked for *every* method, and
|
||||
failures from any method count toward the same 10.
|
||||
|
||||
To stop `begin`-spam from filling the cache with ceremony records, add **one**
|
||||
small limiter that bounds *starts* only — it is a resource guard, not the auth
|
||||
budget:
|
||||
|
||||
```yaml
|
||||
passkey_begin_burst:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%' # default 30
|
||||
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds' # default 60
|
||||
cache_pool: 'passkeyRateLimitCache'
|
||||
```
|
||||
|
||||
plus a `passkeyRateLimitCache` pool (APCu in prod, array in test) and an entry in
|
||||
`tests/TestKernel`'s reset-exclusion list. On over-limit, `begin` answers
|
||||
`429` with `Retry-After`, matching `PublicAccessListener`. (Q3.6 asks whether this
|
||||
guard is wanted at all.)
|
||||
|
||||
### 5.5 Response caching and CSP
|
||||
|
||||
**Caching.** `SecurityHeadersListener` sets `no-store` only on *non-2xx*
|
||||
responses, on the assumption that 2xx is consumed by `forward_auth`. That is
|
||||
false here: `begin` returns a **`200` JSON body straight to the browser**, and
|
||||
the auth subdomain is `reverse_proxy`-ed with no `forward_auth` in front of it at
|
||||
all. Ceremony responses must therefore be no-store too. Proposed: `PasskeyListener`
|
||||
marks them with an internal `X-Preauth-Ceremony` header, and
|
||||
`SecurityHeadersListener` turns that into the full no-store set and strips the
|
||||
marker — keeping the caching policy in the one place that owns it. (Q3.7)
|
||||
|
||||
**CSP.** `publickey-credentials-get` / `publickey-credentials-create` do **not**
|
||||
fall back to `default-src` (confirmed, §2.1), and the current policy is
|
||||
`default-src 'none'`. When passkeys are available the policy becomes:
|
||||
|
||||
```
|
||||
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
|
||||
connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';
|
||||
```
|
||||
|
||||
`connect-src 'self'` must be added **in both modes** — today it is added only for
|
||||
the inline (non-auth-subdomain) case, but the passkey flow always uses `fetch()`.
|
||||
When passkeys are unavailable the header is byte-identical to today.
|
||||
|
||||
### 5.6 Templates and script
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `templates/_passkey.html.twig` | "Sign in with a passkey" button + `navigator.credentials.get()` handler |
|
||||
| `templates/login.html.twig` | gains the checkbox (register) and includes the button, both only when available |
|
||||
| `templates/_passkey_register.html.twig` | `navigator.credentials.create()` handler, driven by the JSON returned in step 1 of §3.1 |
|
||||
|
||||
`_script.html.twig` keeps its existing submit handler; ticking the checkbox
|
||||
switches the submit into the registration branch. Kept as separate templates so
|
||||
the "passkeys unavailable ⇒ byte-identical login page" property stays testable.
|
||||
|
||||
Base64url helpers must mirror the library's encoding exactly (no padding,
|
||||
`-`/`_` alphabet); the spike's working script is the reference.
|
||||
|
||||
### 5.7 Configuration
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PASSKEY_ENABLED` | `0` | master switch; requires **D1 and D4** or the app fails at boot (Q3.1) |
|
||||
| `PASSKEY_RP_NAME` | `%env(TITLE)%` | shown by the OS prompt |
|
||||
| `PASSKEY_USER_VERIFICATION` | `required` | `required`/`preferred`/`discouraged` |
|
||||
| `PASSKEY_TIMEOUT` | `60000` | ms, passed to the browser |
|
||||
| `PASSKEY_BEGIN_BURST_COUNT` / `_TIME` | `30` / `60` | §5.4 resource guard |
|
||||
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | styling-option family |
|
||||
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | checkbox label |
|
||||
|
||||
> **Deleted by D4:** `PASSKEY_ALLOWED_ORIGINS`. The allowed origin is always
|
||||
> derived as `https://{AUTH_SUBDOMAIN}` and there is no override — see §4.2.
|
||||
|
||||
Defaults preserve today's behaviour exactly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Files
|
||||
|
||||
**New**
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/Service/PasskeyManager.php` + `PasskeyInterface.php` | both ceremonies, library types contained |
|
||||
| `src/Service/PasskeyCredentialStore.php` + interface | cache-backed records + index |
|
||||
| `src/Listener/PasskeyListener.php` | priority 70, header dispatch, JSON |
|
||||
| `templates/_passkey.html.twig` | login button + assertion script |
|
||||
| `templates/_passkey_register.html.twig` | registration script |
|
||||
| `tests/Support/PasskeyTestHelper.php` | ES256 generator, ceremony builder, incrementing counter (C4) |
|
||||
| `tests/Unit/Service/PasskeyManagerTest.php` | ceremony control flow |
|
||||
| `tests/Unit/Service/PasskeyCredentialStoreTest.php` | storage, index, persistence, key collisions |
|
||||
| `tests/Unit/Listener/PasskeyListenerTest.php` | every branch |
|
||||
| `tests/Functional/PasskeyFlowTest.php` | real crypto end-to-end (§7.2) |
|
||||
|
||||
**Changed**
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `composer.json` / `composer.lock` / `symfony.lock` | `web-auth/webauthn-lib: ^5.3` (done on the spike branch) |
|
||||
| `phpunit.dist.xml` | recipe-added `doctrine/deprecations` triggers (spike artefact — keep) |
|
||||
| `config/packages/property_info.yaml` | recipe-added (spike artefact — keep) |
|
||||
| `config/services.yaml` | `app.passkey_*` parameters |
|
||||
| `config/packages/rate_limiter.yaml` | `passkey_begin_burst` |
|
||||
| `config/packages/cache.yaml` + `test/cache.yaml` | `passkeyRateLimitCache` |
|
||||
| `config/packages/twig.yaml` | passkey globals |
|
||||
| `src/ConfigBag.php` | `passkeyEnabled()`, `rpName()`, `userVerification()`, `timeout()`, labels |
|
||||
| `src/Kernel.php` or a compiler pass | boot-time check that **D1 and D4** hold when enabled (Q3.1, §4.2) |
|
||||
| `src/Listener/SecurityHeadersListener.php` | CSP additions; ceremony no-store marker |
|
||||
| `src/Listener/LoginListener.php` | detect `register=passkey` on the POST and mark the `Payload` with the intent (see §3.1 note) |
|
||||
| `src/Service/LoginManager.php` | on success-with-intent, delegate to the registration ceremony instead of issuing a session; extract the session-issuing tail (Q3.8) |
|
||||
| `templates/login.html.twig` | checkbox + button |
|
||||
| `tests/TestKernel.php` | `passkeyRateLimitCache` in the reset-exclusion list |
|
||||
| `tests/Support/ListenerTestHelper.php` | passkey limiter factory |
|
||||
| `.env.test`, `docs/examples/.env.example`, `docs/examples/Caddyfile`, `docs/examples/compose.yaml` | config + docs; **TLS dev block** (§4.2) |
|
||||
| `readme.md`, `CHANGELOG.md`, `ROADMAP.md`, `SECURITY.md`, `DESIGN_CONSIDERATIONS.md` | §9 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing
|
||||
|
||||
### 7.1 Reusing the spike
|
||||
|
||||
`tests/Support/PasskeyTestHelper.php` is the spike's working code, refactored:
|
||||
ES256 keypair → COSE key → `authenticatorData` → sign → JSON. Two rules learned
|
||||
the hard way:
|
||||
|
||||
- **Increment the counter every ceremony** (C4), or a negative test can pass for
|
||||
the wrong reason (`CounterException` masking the real failure).
|
||||
- **Serialise options through `WebauthnSerializerFactory`**, never `json_encode` (C2).
|
||||
|
||||
### 7.2 Cases
|
||||
|
||||
| Case | Expected |
|
||||
|---|---|
|
||||
| Register on `auth.example.com` (rpId `example.com`), then assert from the same host | ✅ 303, `__Http-Domain-Preauth`, `Domain=example.com`, `Remote-User` |
|
||||
| Assert the same credential from `app.example.com` | ✅ success by design — asserted explicitly so the scope is documented in code |
|
||||
| Register while `PASSKEY_ENABLED=0` / without central auth | ❌ checkbox absent; `begin` inert; no cache writes |
|
||||
| Registration submitted with a **bad TOTP** | ❌ 401, no ceremony started, login limiter consumed |
|
||||
| Registration with a **spent nonce** | ❌ 401, no ceremony |
|
||||
| `begin` from a rate-limited IP | ❌ 418/429 from `RejectListener`, never reaches passkey code |
|
||||
| Failed assertion | ❌ 401, **login limiter consumed** (D3) |
|
||||
| Failed assertion × N, then a correct TOTP | ❌ still blocked — shared budget |
|
||||
| Assertion replayed with the same `ceremonyId` | ❌ 401 (record deleted on read) |
|
||||
| Unknown `credentialId` | ❌ 401, same generic message as a bad TOTP (no enumeration) |
|
||||
| Origin not in the allow-list | ❌ 401 (`Invalid origin…`) |
|
||||
| **`http://` origin with the derived `https://` allow-list** | ❌ 401 — D4; asserted explicitly so the exemption cannot creep back |
|
||||
| **`PASSKEY_ALLOWED_ORIGINS` is not consulted** | ❌ setting it has no effect (D4) |
|
||||
| **`PASSKEY_ENABLED=1` with `AUTH_SUBDOMAIN=localhost`** | ❌ boot failure — D1 unsatisfiable (§4.2) |
|
||||
| **`PASSKEY_ENABLED=1` on plain HTTP deployment** | ❌ boot failure — D4 (Q3.1) |
|
||||
| Forged `rpIdHash` | ❌ 401 (`rpId hash mismatch`) |
|
||||
| Zero AAGUID / self attestation payload | ✅ accepted exactly as a `none` record would be — documents D5's reasoning in code |
|
||||
| Ceremony responses | ✅ full no-store header set |
|
||||
| Login page when passkeys unavailable | ✅ byte-identical to today |
|
||||
| Persistence | ✅ a saved credential is present in the **persistent** pool, not just APCu |
|
||||
| `begin` spam | ✅ bounded by `passkey_begin_burst` |
|
||||
|
||||
### 7.3 Gates
|
||||
|
||||
Baseline to preserve: **313 tests / 738 assertions**, 100 % line/method/class
|
||||
coverage, `phpstan` level 6 clean, `php-cs-fixer` clean, `composer audit` clean,
|
||||
conformance 35/35. Note `phpunit.dist.xml` runs with `failOnDeprecation=true`, so
|
||||
deprecations from the new dependency must be watched (the recipe already added
|
||||
the `doctrine/deprecations` triggers).
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation order
|
||||
|
||||
Each step is independently committable and leaves the suite green.
|
||||
|
||||
1. **Dependency** *(done on the spike branch)* — `composer require
|
||||
web-auth/webauthn-lib`; suite + lints + audit + conformance verified.
|
||||
2. **Availability + config** — `ConfigBag` accessors, `services.yaml`, boot-time
|
||||
**D1 + D4** assertion, Twig globals, test env. Feature fully inert; assert the
|
||||
login page is unchanged. Includes the TLS development setup in
|
||||
`docs/examples/` (§4.2), so contributors can exercise the feature locally.
|
||||
3. **Credential store** — with `MonitorCacheKeys` wrapping and the persistence
|
||||
test. No WebAuthn types needed yet (`CredentialRecord` can be stubbed).
|
||||
4. **`PasskeyManager`** — both ceremonies, ceremony state, single-use deletion,
|
||||
limiter consumption on failure. Unit-tested with a stubbed validator.
|
||||
5. **`PasskeyListener`** — priority 70, header dispatch, always terminate,
|
||||
no-store marker. Unit-test every branch incl. "post-shaped request must not
|
||||
reach `LoginListener`".
|
||||
6. **Extract session issuing** from `LoginManager` so both paths share it —
|
||||
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
|
||||
before touching anything else (Q3.8).
|
||||
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
|
||||
hand-off described in §3.1, `_passkey_register.html.twig`.
|
||||
8. **Login UI + CSP** — `_passkey.html.twig`, `SecurityHeadersListener`, extend
|
||||
`CacheControlFlowTest` and `SecurityHeadersListenerTest`.
|
||||
9. **Functional tests** with real crypto (§7.2).
|
||||
10. **Docs** (§9) and **final gates**, then PR to `main`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
| File | Update |
|
||||
|---|---|
|
||||
| `readme.md` | "Passkey Authentication" section: **the central-auth prerequisite**, **the HTTPS requirement (development included)**, enabling, the checkbox, the passkey button, RP ID, fallbacks |
|
||||
| `CHANGELOG.md` | `[Unreleased]` `Added`/`Security`; record library 5.3.9 and the clean audit |
|
||||
| `ROADMAP.md` | Phase 2c done, noting the deviations from the original sketch (browser registration, no bundle, D1/D3/D4/D5) |
|
||||
| `SECURITY.md` | ceremony model, challenge TTL/one-shot, RP ID scope, **the D5 attestation rationale and the conditions that would reverse it (§2.3)**, HTTPS-only origins, counter caveat, shared rate-limit budget |
|
||||
| `DESIGN_CONSIDERATIONS.md` | the 2xx-caching gap; `CredentialRecord` serialization; the shared-limiter decision; **why attestation was deliberately declined** |
|
||||
| `docs/examples/.env.example` | new variables; **note that `PASSKEY_ALLOWED_ORIGINS` does not exist by design** |
|
||||
| `docs/examples/Caddyfile` | auth-subdomain block already `reverse_proxy`-ed; **add a TLS-enabled development block (§4.2)** and note why the plain-HTTP shortcut is not offered |
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks
|
||||
|
||||
| # | Risk | Mitigation |
|
||||
|---|---|---|
|
||||
| R1 | RP ID / origin misconfiguration | D1 removes the matrix: RP ID is always `authBase()`, origins is always the auth host. Asserted by tests. |
|
||||
| R2 | Ceremony responses cached (first browser-facing 2xx) | §5.5 marker + `CacheControlFlowTest` cases |
|
||||
| R3 | CSP blocks the ceremony | §5.5 directives; verify in a real browser during staging (Q3.9) |
|
||||
| R4 | Library churn (v5 renamed types; `setSecuredRelyingPartyId` deprecated) | pin `^5.3`; library types contained in `PasskeyManager`; avoid deprecated calls |
|
||||
| R5 | Credential loss on restart | `MonitorCacheKeys` wrap + explicit persistence test (§5.3) |
|
||||
| R6 | Non-technical users lose their passkey device | TOTP/backup codes unchanged and always available; the checkbox is opt-in |
|
||||
| R7 | `begin` cache-fill | §5.4 resource guard |
|
||||
| R8 | New transitive deps (`symfony/serializer`, `property-info`) | already installed as part of the spike; container lint passes |
|
||||
| R9 | **A deployment enables passkeys without TLS, and the feature silently half-works** | D4 + the extended boot assertion (§4.2, Q3.1): `PASSKEY_ENABLED=1` in a non-HTTPS configuration **fails at `cache:warmup`** instead of failing later in the browser |
|
||||
| R10 | **"We should verify the device" creeps back in as a requirement** | §2.3 records the measurements and the two conditions that would justify revisiting; a functional test asserts a zero-AAGUID payload is handled deliberately, so any change is a visible, reviewed diff |
|
||||
|
||||
---
|
||||
|
||||
## 11. Remaining open questions
|
||||
|
||||
D1–D5 removed most of the first draft's 22 questions. These are what is left;
|
||||
each has a proposal, so "yes" is a valid answer.
|
||||
|
||||
**Q1.1 — Version target.** `CHANGELOG.md`'s `[Unreleased]` heading still says
|
||||
v1.1 while git tags reach `v1.3.0`. Target the next minor and repair the heading
|
||||
in a separate labelled commit? *Proposal: yes.*
|
||||
|
||||
**Q1.2 — Where the checkbox appears.** *Proposal: always visible when passkeys
|
||||
are available (same as the login button), since a user who has just landed on
|
||||
the auth page is exactly the person most likely to be enrolling a new device.*
|
||||
|
||||
**Q1.3 — What if the same device registers twice** (same identity, second
|
||||
passkey)? *Proposal: allow it — the OS may legitimately create a second
|
||||
credential, and `excludeCredentials` will let the authenticator dedupe. `Q2.6`
|
||||
of the first draft (a cap) becomes: cap at a configurable N (default 20).*
|
||||
|
||||
**Q2.1 — Re-confirm: TOTP stays?** *Proposal: yes, unchanged, and never
|
||||
disabled by enabling passkeys.*
|
||||
|
||||
**Q3.1 — How to enforce the D1 prerequisite.** Boot-time hard failure when
|
||||
`PASSKEY_ENABLED=1` without central auth, or log a warning and disable?
|
||||
*Proposal: **hard failure** at container start (`cache:warmup`) — a silent
|
||||
disable is how you get "my passkey stopped working" tickets.* **Extended by D4:**
|
||||
the same boot check also asserts HTTPS, so "enabled but unusable" cannot ship.
|
||||
The check is on **configuration**, not on the request, because behind a TLS
|
||||
terminating proxy `isSecure()` is not authoritative (§4.2).
|
||||
|
||||
**Q3.2 — Always terminate a ceremony with JSON?** *Proposal: yes — any request
|
||||
carrying `X-Preauth-Passkey` gets a JSON response, never the HTML login page.*
|
||||
|
||||
**Q3.3 — Attestation policy.** **RESOLVED — D5: `none`.** Measured, not assumed:
|
||||
`direct` cannot be enforced (config D), MDS is bypassable by the zero AAGUIDs that
|
||||
real passkeys send (config C3), and requiring MDS would reject legitimate new
|
||||
authenticators (config C2) while adding two dependencies. Full evidence and the
|
||||
conditions that would reverse it are in **§2.3**. *Set a real value instead*
|
||||
was considered and rejected on the evidence.
|
||||
|
||||
**Q3.4 — Local development over HTTP.** **RESOLVED — D4: not supported.** No
|
||||
`securedRelyingPartyId` exemption, deprecated or otherwise; local development uses
|
||||
real TLS with a local certificate (§4.2). `PASSKEY_ALLOWED_ORIGINS` is deleted.
|
||||
Note `localhost` deliberately cannot satisfy D1, so there is no half-configured
|
||||
state to document away.
|
||||
|
||||
**Q3.5 — Counter checking.** **Resolved during implementation: the proposal was
|
||||
wrong and was reversed.** The assumption "many passkeys always report 0" was
|
||||
correct, but the conclusion "so the default is harmless" was not — the default
|
||||
*rejects* a reported 0 against a stored 0, so the very case it was assumed to
|
||||
tolerate is the case it fails. Replaced with `PasskeyCounterChecker` (accept
|
||||
`>=`, reject strictly backwards). See §2.2 C5.
|
||||
|
||||
**Q3.6 — Keep the `begin` resource guard?** It is not part of the login budget
|
||||
(D3 governs that) — it only bounds cache-fill. *Proposal: keep it; it is ~15
|
||||
lines and mirrors the existing `public_limiter` pattern.*
|
||||
|
||||
**Q3.7 — Caching-policy mechanism.** `X-Preauth-Ceremony` marker header consumed
|
||||
by `SecurityHeadersListener` (keeps cache policy in one place), or set headers
|
||||
directly in `PasskeyListener`? *Proposal: the marker.*
|
||||
|
||||
**Q3.8 — Extract the session-issuing tail from `LoginManager`?** *Proposal: yes,
|
||||
as its own commit — duplicating cookie/redirect/`Remote-User` logic is how the
|
||||
two paths drift.*
|
||||
|
||||
**Q3.9 — Browser matrix.** Which browsers must be verified by hand on staging
|
||||
(iOS Safari, Chrome, Firefox, and a hardware key) before release? *Proposal: all
|
||||
four; note the CSP directive is the most likely divergence.*
|
||||
|
||||
---
|
||||
|
||||
### Resolved in this round
|
||||
|
||||
| Question | Resolution |
|
||||
|---|---|
|
||||
| Q3.3 — attestation value | **D5: `none`**, with measurements in §2.3 |
|
||||
| Q3.4 — dev over HTTP | **D4: real TLS only**; `PASSKEY_ALLOWED_ORIGINS` deleted (§4.2) |
|
||||
| Q3.1 — boot check scope | extended to assert **D1 *and* D4** |
|
||||
|
||||
---
|
||||
|
||||
## 12. Rollback
|
||||
|
||||
- `PASSKEY_ENABLED=0` (the default) makes the feature inert; reverting is
|
||||
redeploying the previous image tag. No migrations.
|
||||
- If passkeys were enabled and are rolled back, credential records remain in
|
||||
`sessionCache`/filesystem under `passkey_*` keys, unread by the old code.
|
||||
Sessions continue to work; nothing is invalidated.
|
||||
- The dependency addition reverts with `composer.lock`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Notes for the reviewer
|
||||
|
||||
- **Round 2 added D4 (HTTPS required, no exemptions) and D5 (attestation stays
|
||||
`none`, on measured evidence).** D4 is covered in §4.2, D5 in §2.3; the two
|
||||
questions that drove them are marked resolved in §11.
|
||||
- Three claims in this revision were **measured, not reasoned**: the attestation
|
||||
matrix (§2.3), the origin/HTTPS behaviour (§4.2), and the `localhost` × D1
|
||||
interaction (§4.2). Scripts: `spike_attestation.php`, `spike_att2.php`,
|
||||
`spike_origin.php`, `spike_devhost.php`.
|
||||
- Incidentally confirmed while testing: `symfony/http-client` is **not** in the
|
||||
current install, so MDS would have been a second new dependency, not a drop-in.
|
||||
- The spike branch (`spike/passkey-deps`) currently carries `composer.json`,
|
||||
`composer.lock`, `symfony.lock`, `phpunit.dist.xml` and
|
||||
`config/packages/property_info.yaml` changes. Decide whether step 2 continues
|
||||
on that branch or starts fresh from `main`.
|
||||
- The spike scripts themselves were **removed** from the working tree (kept in
|
||||
`/tmp/spike-backup/` for reference) so they never reach a PR; the reusable
|
||||
parts are folded into `tests/Support/PasskeyTestHelper.php` in step 9.
|
||||
- The environment details (PHP 8.5.11 via Sury, Composer, `pcov`) are local to
|
||||
this container and are not a project change.
|
||||
|
||||
---
|
||||
|
||||
*End of plan.*
|
||||
@@ -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,949 @@
|
||||
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\\PasskeyListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: src/Listener/PasskeyListener.php
|
||||
|
||||
-
|
||||
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: src/Listener/PasskeyListener.php
|
||||
|
||||
-
|
||||
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||
identifier: property.readOnlyAssignNotInConstructor
|
||||
count: 2
|
||||
path: src/Listener/PasskeyListener.php
|
||||
|
||||
-
|
||||
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||
identifier: property.readOnlyAssignNotInConstructor
|
||||
count: 1
|
||||
path: src/Listener/PasskeyListener.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\\SessionIssuer has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: src/Service/SessionIssuer.php
|
||||
|
||||
-
|
||||
message: '#^Readonly property App\\Service\\SessionIssuer\:\:\$logger is assigned outside of the constructor\.$#'
|
||||
identifier: property.readOnlyAssignNotInConstructor
|
||||
count: 1
|
||||
path: src/Service/SessionIssuer.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: '#^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: '#^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: '#^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 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 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 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 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: '#^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: '#^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: '#^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: '#^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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -22,6 +22,12 @@
|
||||
<!-- 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>
|
||||
@@ -40,6 +46,8 @@
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
+1
-1
@@ -6,6 +6,6 @@ use App\Kernel;
|
||||
|
||||
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']);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ For when you want a belt and suspenders.
|
||||
- **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
|
||||
@@ -42,7 +44,7 @@ docker pull digitaladapt/preauth:latest
|
||||
openssl rand -base64 30
|
||||
```
|
||||
|
||||
Create a `.env` file (see `docs/example.env` for all options):
|
||||
Create a `.env` file (see `docs/examples/.env.example` for all options):
|
||||
|
||||
```env
|
||||
APP_SECRET=your-random-secret-here
|
||||
@@ -59,7 +61,7 @@ COOKIE_TTL=2592000
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See `docs/compose.yaml` for an example Docker Compose file.
|
||||
See `docs/examples/compose.yaml` for an example Docker Compose file.
|
||||
|
||||
### 4. Configure Caddy
|
||||
|
||||
@@ -68,13 +70,24 @@ 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/Caddyfile` for more examples, including path-specific protection
|
||||
and central auth subdomain configuration.
|
||||
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)
|
||||
|
||||
@@ -93,7 +106,7 @@ capabilities may work, but only Caddy is officially supported.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables. See `docs/example.env`
|
||||
All configuration is via environment variables. See `docs/examples/.env.example`
|
||||
for the complete reference.
|
||||
|
||||
### Main Options
|
||||
@@ -111,6 +124,7 @@ for the complete reference.
|
||||
|----------|---------|-------------|
|
||||
| `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
|
||||
|
||||
@@ -138,6 +152,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
||||
| `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:
|
||||
@@ -168,10 +232,12 @@ 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. **RejectListener** (priority 77) — Rate-limiting gate.
|
||||
4. **LoginListener** (priority 66) — Processes login attempts.
|
||||
5. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||
6. **SecurityHeadersListener** (response) — Adds security headers.
|
||||
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
|
||||
|
||||
@@ -182,6 +248,14 @@ passes through a priority-ordered chain of listeners:
|
||||
- **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
|
||||
|
||||
@@ -213,7 +287,7 @@ vendor/bin/php-cs-fixer fix
|
||||
vendor/bin/phpunit
|
||||
```
|
||||
|
||||
The test suite includes 222 tests with 100% code coverage (lines, methods,
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\CacheWarmer;
|
||||
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
|
||||
|
||||
/**
|
||||
* Fails the build (or container start) when passkeys are enabled in a
|
||||
* configuration that cannot support them.
|
||||
*
|
||||
* `docker/entrypoint.sh` runs `cache:warmup` on every production boot with the
|
||||
* real environment already injected, so a misconfiguration is caught while the
|
||||
* container is starting — the deployment aborts — rather than surfacing later as
|
||||
* a passkey button that silently never works.
|
||||
*
|
||||
* The warmer is **not** optional: an optional warmer may be skipped, which would
|
||||
* let a bad configuration through.
|
||||
*/
|
||||
final readonly class PasskeyConfigurationWarmer implements CacheWarmerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private PasskeyPolicyInterface $passkeyPolicy,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*
|
||||
* @throws PasskeyConfigurationException when passkeys are enabled but unusable
|
||||
*/
|
||||
#[Override]
|
||||
public function warmUp(string $cacheDir, ?string $buildDir = null): array
|
||||
{
|
||||
$this->passkeyPolicy->assertConfigurationIsUsable();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Never optional: skipping this warmer would defeat its entire purpose.
|
||||
*/
|
||||
#[Override]
|
||||
public function isOptional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ use App\Service\BackupCodeInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
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\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
|
||||
|
||||
/** simple console command to generate backup codes
|
||||
* usage: php bin/console app:generate-backup-codes [count] */
|
||||
@@ -21,7 +21,7 @@ final class GenerateBackupCodesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BackupCodeInterface $manager,
|
||||
private readonly PersistCache $persistCache,
|
||||
private readonly PersistCache $persistCache,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -46,6 +46,7 @@ final class GenerateBackupCodesCommand extends Command
|
||||
$output->writeln($code);
|
||||
}
|
||||
$this->persistCache->persist();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
+74
-19
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App;
|
||||
|
||||
use App\Enum\RemoteUserMode;
|
||||
use App\Enum\UserVerification;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -23,34 +24,52 @@ final readonly class ConfigBag
|
||||
private string $remoteUserStatic;
|
||||
/** @var array<string,string> */
|
||||
private array $remoteUserMap;
|
||||
private string $title;
|
||||
private bool $passkeyEnabled;
|
||||
private string $passkeyRpName;
|
||||
private UserVerification $passkeyUserVerification;
|
||||
private int $passkeyTimeout;
|
||||
|
||||
/** Passkey ceremony timeout in milliseconds (WebAuthn default). */
|
||||
private const int DEFAULT_PASSKEY_TIMEOUT = 60000;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
Utilities $utilities,
|
||||
ClockInterface $clock,
|
||||
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
|
||||
#[Autowire('%app.totp_uri%')] string $totpUri,
|
||||
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
||||
#[Autowire('%app.teapot%')] bool $teapot,
|
||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||
Utilities $utilities,
|
||||
ClockInterface $clock,
|
||||
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
|
||||
#[Autowire('%app.totp_uri%')] string $totpUri,
|
||||
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
||||
#[Autowire('%app.teapot%')] bool $teapot,
|
||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
||||
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
||||
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
||||
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
|
||||
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
|
||||
#[Autowire('%app.title%')] string $title = 'Pre-Authentication System',
|
||||
#[Autowire('%app.passkey_enabled%')] bool $passkeyEnabled = false,
|
||||
#[Autowire('%app.passkey_rp_name%')] string $passkeyRpName = '',
|
||||
#[Autowire('%app.passkey_user_verification%')] string $passkeyUserVerification = 'required',
|
||||
#[Autowire('%app.passkey_timeout%')] int $passkeyTimeout = self::DEFAULT_PASSKEY_TIMEOUT,
|
||||
) {
|
||||
$this->clock = $clock;
|
||||
$this->cookieTtl = $cookieTtl;
|
||||
$this->totpUri = $totpUri ?: $utilities->loadTotp();
|
||||
$this->ipTtl = $ipTtl ?: null;
|
||||
$this->teapot = $teapot;
|
||||
$this->clock = $clock;
|
||||
$this->cookieTtl = $cookieTtl;
|
||||
$this->totpUri = $totpUri ?: $utilities->loadTotp();
|
||||
$this->ipTtl = $ipTtl ?: null;
|
||||
$this->teapot = $teapot;
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->teapotTitle = $teapotTitle;
|
||||
$this->teapotTitle = $teapotTitle;
|
||||
$this->tooManyTitle = $tooManyTitle;
|
||||
|
||||
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
||||
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
||||
$this->remoteUserStatic = $remoteUserStatic;
|
||||
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
|
||||
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
|
||||
$this->title = $title;
|
||||
$this->passkeyEnabled = $passkeyEnabled;
|
||||
$this->passkeyRpName = $passkeyRpName;
|
||||
$this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification);
|
||||
$this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,17 +79,18 @@ final readonly class ConfigBag
|
||||
*/
|
||||
private function parseUserMap(string $map): array
|
||||
{
|
||||
if ($map === '') {
|
||||
if ('' === $map) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (explode(',', $map) as $pair) {
|
||||
$parts = explode(':', trim($pair), 2);
|
||||
if (count($parts) === 2) {
|
||||
if (2 === \count($parts)) {
|
||||
$result[trim($parts[0])] = trim($parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -131,4 +151,39 @@ final readonly class ConfigBag
|
||||
{
|
||||
return $this->remoteUserMap;
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the passkey feature is switched on by configuration.
|
||||
*
|
||||
* This says nothing about whether the configuration is *usable* — that is
|
||||
* {@see Service\PasskeyPolicyInterface::isEnabled()}, which also
|
||||
* requires the central-auth prerequisite (D1).
|
||||
*/
|
||||
public function passkeyEnabled(): bool
|
||||
{
|
||||
return $this->passkeyEnabled;
|
||||
}
|
||||
|
||||
/** Relying-party name shown in the authenticator prompt; blank falls back to the title. */
|
||||
public function passkeyRpName(): string
|
||||
{
|
||||
return $this->passkeyRpName;
|
||||
}
|
||||
|
||||
/** User-verification requirement; an unrecognised value falls back to `required`. */
|
||||
public function passkeyUserVerification(): string
|
||||
{
|
||||
return $this->passkeyUserVerification->value;
|
||||
}
|
||||
|
||||
/** Ceremony timeout in milliseconds. */
|
||||
public function passkeyTimeout(): int
|
||||
{
|
||||
return $this->passkeyTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Webauthn\CredentialRecord;
|
||||
|
||||
/**
|
||||
* A stored passkey: the WebAuthn credential plus the application metadata that
|
||||
* ties it to a visitor of this gateway.
|
||||
*
|
||||
* The credential itself carries the public key and signature counter; the
|
||||
* metadata here records *whose* passkey it is and when it was used. The identity
|
||||
* is deliberately read from this record on assertion rather than from the value
|
||||
* the client returns, which is never trusted.
|
||||
*/
|
||||
final readonly class PasskeyCredential
|
||||
{
|
||||
public function __construct(
|
||||
public CredentialRecord $record,
|
||||
/** The session id this passkey authenticates, as typed at registration. */
|
||||
public string $identity,
|
||||
/** Operator-facing description, shown so a user can tell their keys apart. */
|
||||
public string $label,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public ?DateTimeImmutable $lastUsedAt = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of this credential with the signature counter and last-used time
|
||||
* refreshed after a successful assertion.
|
||||
*
|
||||
* The counter is only ever observed, never enforced — many passkeys report a
|
||||
* constant zero, so treating it as a clone signal would lock users out.
|
||||
*/
|
||||
public function withUsage(CredentialRecord $updated, DateTimeImmutable $usedAt): self
|
||||
{
|
||||
return new self(
|
||||
$updated,
|
||||
$this->identity,
|
||||
$this->label,
|
||||
$this->createdAt,
|
||||
$usedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
+22
-20
@@ -14,59 +14,61 @@ final class Payload
|
||||
public string $id; /* session name, identifying who is logging in */
|
||||
public string $token; /* TOTP, typically six digits */
|
||||
public string $nonce; /* random unique string, to block duplicate submissions */
|
||||
public bool $json; /* should we return json (for the login page) */
|
||||
public Scope $scope; /* type of access being requested */
|
||||
public bool $json; /* should we return json (for the login page) */
|
||||
public Scope $scope; /* type of access being requested */
|
||||
|
||||
public static function decode(string $base64url): ?Payload
|
||||
public static function decode(string $base64url): ?self
|
||||
{
|
||||
/* convert the base64url into json string */
|
||||
$base64 = strtr($base64url, '-_', '+/');
|
||||
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
||||
$base64 .= str_repeat('=', (4 - \strlen($base64) % 4) % 4);
|
||||
$json = base64_decode($base64, true);
|
||||
if ($json) {
|
||||
/* convert the json string into real data */
|
||||
$data = json_decode($json);
|
||||
if (is_object($data)) {
|
||||
return Payload::create($data);
|
||||
if (\is_object($data)) {
|
||||
return self::create($data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function load(InputBag $input): ?Payload
|
||||
public static function load(InputBag $input): ?self
|
||||
{
|
||||
/* convert form data into real data */
|
||||
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
|
||||
return Payload::create((object)[
|
||||
'id' => $input->get('username'),
|
||||
return self::create((object) [
|
||||
'id' => $input->get('username'),
|
||||
'nonce' => $input->get('nonce'),
|
||||
'token' => $input->get('totp'),
|
||||
'json' => false,
|
||||
'json' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
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 (strlen(trim($data->id ?? '')) < 1 ||
|
||||
strlen(trim($data->nonce ?? '')) < 1 ||
|
||||
strlen(trim($data->token ?? '')) < 1
|
||||
if ('' === trim($data->id ?? '')
|
||||
|| '' === trim($data->nonce ?? '')
|
||||
|| '' === trim($data->token ?? '')
|
||||
) {
|
||||
/* returns null as the input is invalid */
|
||||
return null;
|
||||
}
|
||||
|
||||
/* all input is limited */
|
||||
$payload = new Payload();
|
||||
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
$payload = new self();
|
||||
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
$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->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
|
||||
return Payload::constrict($payload);
|
||||
return self::constrict($payload);
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
@@ -74,10 +76,10 @@ final class Payload
|
||||
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. */
|
||||
if ($payload->scope === Scope::None) {
|
||||
if (Scope::None === $payload->scope) {
|
||||
$payload->json = false;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ namespace App\Enum;
|
||||
enum Scope: string
|
||||
{
|
||||
case Cookie = 'cookie';
|
||||
case Ip = 'ip';
|
||||
case None = 'none';
|
||||
case Ip = 'ip';
|
||||
case None = 'none';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* User-verification requirement passed to the authenticator at passkey time.
|
||||
*
|
||||
* Mirrors the WebAuthn `userVerification` option without leaking the library's
|
||||
* constants into application configuration.
|
||||
*/
|
||||
enum UserVerification: string
|
||||
{
|
||||
/** Require a biometric/PIN check (default). */
|
||||
case Required = 'required';
|
||||
|
||||
/** Ask for it, but allow a plain user-presence tap to succeed. */
|
||||
case Preferred = 'preferred';
|
||||
|
||||
/** Never prompt for verification; presence alone is enough. */
|
||||
case Discouraged = 'discouraged';
|
||||
|
||||
/**
|
||||
* Parse a configured value, falling back to the safest option.
|
||||
*
|
||||
* An unrecognised value must never silently weaken the requirement, so the
|
||||
* fallback is the strictest case rather than the most permissive one.
|
||||
*/
|
||||
public static function fromConfig(string $value): self
|
||||
{
|
||||
return self::tryFrom(trim($value)) ?? self::Required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown when passkeys are enabled in a configuration that cannot support them.
|
||||
*
|
||||
* This is deliberately fatal: the alternative is a feature that appears to be
|
||||
* switched on but cannot complete a single ceremony, which surfaces to the user
|
||||
* as "my passkey stopped working" rather than as a deployment error.
|
||||
*
|
||||
* Raised during cache warm-up so that a misconfigured container fails to start
|
||||
* instead of failing later, in a browser.
|
||||
*/
|
||||
final class PasskeyConfigurationException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
@@ -21,9 +22,9 @@ final readonly class AcceptListener
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ final readonly class AcceptListener
|
||||
{
|
||||
/* check if they sent the correct preauth cookie */
|
||||
$cookieName = $this->sessionCookieName($this->domainManager);
|
||||
if (! $event->getRequest()->cookies->has($cookieName)) {
|
||||
if (!$event->getRequest()->cookies->has($cookieName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,13 +41,13 @@ final readonly class AcceptListener
|
||||
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
||||
|
||||
try {
|
||||
if (! $cookie || ! $this->sessionCache->hasItem($cookieKey)) {
|
||||
if (!$cookie || !$this->sessionCache->hasItem($cookieKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* cookie sent corresponds to valid existing session */
|
||||
$item = $this->sessionCache->getItem($cookieKey);
|
||||
if (! $item->isHit()) {
|
||||
if (!$item->isHit()) {
|
||||
/* race condition: item was removed between hasItem and getItem */
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
@@ -18,8 +19,8 @@ final readonly class AllowListener
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private ConfigBag $config,
|
||||
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -33,13 +34,13 @@ final readonly class AllowListener
|
||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||
|
||||
try {
|
||||
if (! $this->sessionCache->hasItem($ipKey)) {
|
||||
if (!$this->sessionCache->hasItem($ipKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* ip address corresponds to valid existing session */
|
||||
$item = $this->sessionCache->getItem($ipKey);
|
||||
if (! $item->isHit()) {
|
||||
if (!$item->isHit()) {
|
||||
/* race condition: item was removed between hasItem and getItem */
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ final readonly class InterceptListener
|
||||
use MakeNonceTrait;
|
||||
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
private Environment $twig,
|
||||
private Environment $twig,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,29 +39,29 @@ final readonly class InterceptListener
|
||||
/* by this point, we know that the request we have is:
|
||||
* not already authorized, nor already rate-limited,
|
||||
* nor submitting login credentials; so redirect or present the login page now */
|
||||
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() &&
|
||||
$this->domainManager->matchesAuth($event->getRequest()->getHost())
|
||||
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost()
|
||||
&& $this->domainManager->matchesAuth($event->getRequest()->getHost())
|
||||
) {
|
||||
/* host matches base-domain of auth, but not on auth subdomain, redirect */
|
||||
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
|
||||
$event->setResponse(new Response(
|
||||
'',
|
||||
Response::HTTP_SEE_OTHER,
|
||||
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
|
||||
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"],
|
||||
));
|
||||
} else {
|
||||
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
||||
$content = $this->twig->render('login.html.twig', [
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
||||
]);
|
||||
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
||||
$this->sessionCookieName($this->domainManager)
|
||||
$this->sessionCookieName($this->domainManager),
|
||||
);
|
||||
$event->setResponse($this->pruneInvalidCookie(new Response(
|
||||
$content,
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['Content-Type' => 'text/html']
|
||||
['Content-Type' => 'text/html'],
|
||||
), $hasCookie, $event->getRequest()->getHost()));
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ final readonly class InterceptListener
|
||||
$this->sessionCookieDomain($this->domainManager, $host),
|
||||
true,
|
||||
true,
|
||||
Cookie::SAMESITE_STRICT
|
||||
Cookie::SAMESITE_STRICT,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,28 +43,28 @@ final readonly class LoginListener
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
private DomainInterface $domainManager,
|
||||
private LoginInterface $loginManager,
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
private LoginInterface $loginManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
||||
#[AsEventListener(priority: 66)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$payload = null;
|
||||
$payload = null;
|
||||
$response = null;
|
||||
|
||||
if ($event->getRequest()->headers->has($this->headerName())) {
|
||||
/* if request contains our "X-Preauth" header */
|
||||
$data = $event->getRequest()->headers->get($this->headerName());
|
||||
$payload = Payload::decode($data);
|
||||
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
|
||||
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
||||
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST)
|
||||
&& $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
||||
) {
|
||||
/* if request is a POST to the auth-subdomain */
|
||||
$payload = Payload::load($event->getRequest()->getPayload());
|
||||
@@ -80,6 +80,7 @@ final readonly class LoginListener
|
||||
/* token or backup-code authentication was successful */
|
||||
if ($response) {
|
||||
$event->setResponse($response);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -92,14 +93,15 @@ final readonly class LoginListener
|
||||
$limitReached,
|
||||
$payload?->json ?? true,
|
||||
$event->getRequest()->getHost(),
|
||||
$this->makeCacheKey($payload?->id ?? '')
|
||||
$this->makeCacheKey($payload?->id ?? ''),
|
||||
));
|
||||
}
|
||||
|
||||
private function logFailure(Request $request): bool
|
||||
{
|
||||
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||
return ($limiter->consume(1)->getRemainingTokens() < 1);
|
||||
|
||||
return $limiter->consume(1)->getRemainingTokens() < 1;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
@@ -111,24 +113,24 @@ final readonly class LoginListener
|
||||
$message = $this->config->teapot() ? $this->config->teapotTitle()
|
||||
: $this->config->tooManyTitle();
|
||||
} else {
|
||||
$status = Response::HTTP_UNAUTHORIZED;
|
||||
$status = Response::HTTP_UNAUTHORIZED;
|
||||
$message = $this->config->errorMessage();
|
||||
}
|
||||
$answer = [
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $host,
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $host,
|
||||
'username' => $username,
|
||||
];
|
||||
|
||||
if ($json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode($answer);
|
||||
$content = json_encode($answer);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$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,316 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\Scope;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Service\SessionIssuerInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
|
||||
/**
|
||||
* Serves the passkey ceremonies.
|
||||
*
|
||||
* **Priority 70 — the whole design turns on this number.**
|
||||
*
|
||||
* - *After* `RejectListener` (77), so a rate-limited IP never reaches this code.
|
||||
* Passkeys cannot be used to sidestep a lockout; that is decision D3.
|
||||
* - *Before* `LoginListener` (66), which is essential rather than tidy:
|
||||
* `LoginListener` treats **any** POST to the auth subdomain as a login attempt,
|
||||
* and a ceremony `finish` body has no `username`/`totp`, so `Payload::load()`
|
||||
* returns null and the request would be scored as a failed login — burning a
|
||||
* rate-limit token for every legitimate passkey login.
|
||||
*
|
||||
* **Every** request carrying the dispatch header gets a response, including
|
||||
* malformed ones. Falling through would let `InterceptListener` render HTML to a
|
||||
* `fetch()` caller.
|
||||
*/
|
||||
final readonly class PasskeyListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
|
||||
/**
|
||||
* Marks a request as a ceremony call, and its value selects the operation.
|
||||
*
|
||||
* A distinct header rather than overloading `X-Preauth`: that one carries a
|
||||
* base64url `Payload` and is parsed as such.
|
||||
*/
|
||||
public const string HEADER = 'X-Preauth-Passkey';
|
||||
|
||||
/** Marks a response as ceremony output, so the caching policy can see it. */
|
||||
public const string CEREMONY_MARKER = 'X-Preauth-Ceremony';
|
||||
|
||||
private const string BEGIN_LOGIN = 'login-begin';
|
||||
|
||||
private const string FINISH_LOGIN = 'login-finish';
|
||||
|
||||
private const string BEGIN_REGISTER = 'register-begin';
|
||||
|
||||
private const string FINISH_REGISTER = 'register-finish';
|
||||
|
||||
private RateLimiterFactoryInterface $beginLimiter;
|
||||
|
||||
private RateLimiterFactoryInterface $loginLimiter;
|
||||
|
||||
public function __construct(
|
||||
#[Target('passkey_begin_burst')] RateLimiterFactoryInterface $beginLimiter,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $loginLimiter,
|
||||
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
|
||||
private PasskeyInterface $passkeys,
|
||||
private PasskeyPolicyInterface $policy,
|
||||
private SessionIssuerInterface $sessionIssuer,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->beginLimiter = $beginLimiter;
|
||||
$this->loginLimiter = $loginLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[AsEventListener(priority: 70)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
$operation = $request->headers->get(self::HEADER);
|
||||
|
||||
if (null === $operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Ceremonies exist only on the auth subdomain. Elsewhere the header is
|
||||
* ignored entirely, so this listener cannot be used to probe other hosts. */
|
||||
if ($this->domainManager->getAuthSubdomain() !== $request->getHost()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($this->dispatch($operation, $request));
|
||||
}
|
||||
|
||||
private function dispatch(string $operation, Request $request): Response
|
||||
{
|
||||
/* not available => behave as if the feature does not exist */
|
||||
if (!$this->policy->isAvailableFor($request)) {
|
||||
return $this->ceremonyResponse($this->error('Passkeys are not available.', $request));
|
||||
}
|
||||
|
||||
return $this->ceremonyResponse(match ($operation) {
|
||||
self::BEGIN_LOGIN => $this->beginLogin($request),
|
||||
self::FINISH_LOGIN => $this->finishLogin($request),
|
||||
self::BEGIN_REGISTER => $this->beginRegistration($request),
|
||||
self::FINISH_REGISTER => $this->finishRegistration($request),
|
||||
default => $this->error('Unknown passkey operation.', $request),
|
||||
});
|
||||
}
|
||||
|
||||
private function beginLogin(Request $request): Response
|
||||
{
|
||||
if ($limit = $this->beginBurstExceeded($request)) {
|
||||
return $limit;
|
||||
}
|
||||
|
||||
return $this->json($this->passkeys->beginLogin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration is only offered to someone who already authenticated: the
|
||||
* identity comes from the live session, never from the request body, so a
|
||||
* caller cannot register a passkey for an identity it does not hold.
|
||||
*/
|
||||
private function beginRegistration(Request $request): Response
|
||||
{
|
||||
$identity = $this->identityFromRequest($request);
|
||||
if (null === $identity) {
|
||||
return $this->error('Registration requires a completed login.', $request);
|
||||
}
|
||||
|
||||
if ($limit = $this->beginBurstExceeded($request)) {
|
||||
return $limit;
|
||||
}
|
||||
|
||||
return $this->json($this->passkeys->beginRegistration($identity));
|
||||
}
|
||||
|
||||
private function finishLogin(Request $request): Response
|
||||
{
|
||||
$credential = $this->passkeys->finishLogin($this->body($request));
|
||||
|
||||
if (null === $credential) {
|
||||
/* A failed ceremony consumes the same budget as a wrong TOTP code
|
||||
* (D3), so passkey guesses cannot outpace code guesses. */
|
||||
return $this->failure($request);
|
||||
}
|
||||
|
||||
$this->logger->debug("passkey login succeeded for: {$credential->identity}");
|
||||
|
||||
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
|
||||
}
|
||||
|
||||
private function finishRegistration(Request $request): Response
|
||||
{
|
||||
$credential = $this->passkeys->finishRegistration($this->body($request));
|
||||
|
||||
if (null === $credential) {
|
||||
return $this->failure($request);
|
||||
}
|
||||
|
||||
$this->logger->debug("passkey registered for: {$credential->identity}");
|
||||
|
||||
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity of an already-authenticated caller, for registration.
|
||||
*
|
||||
* Read from the live session cookie, so `register-begin` is reachable only by
|
||||
* someone who has just passed the TOTP check. Null when there is no session.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function identityFromRequest(Request $request): ?string
|
||||
{
|
||||
$cookie = $request->cookies->get($this->sessionCookieName($this->domainManager));
|
||||
if (!\is_string($cookie) || '' === $cookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = $this->sessionCache->getItem($this->makeCacheKey("cookie_$cookie"));
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$identity = $item->get();
|
||||
|
||||
return \is_string($identity) && '' !== $identity ? $identity : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds how many ceremonies one caller can start.
|
||||
*
|
||||
* A resource guard, not the login budget: a legitimate `begin` must not spend
|
||||
* failure budget, but an unbounded `begin` could fill the ceremony cache.
|
||||
*/
|
||||
private function beginBurstExceeded(Request $request): ?Response
|
||||
{
|
||||
$limit = $this->beginLimiter->create((string) $request->getClientIp())->consume(1);
|
||||
if ($limit->isAccepted()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$retryAfter = max(1, $limit->getRetryAfter()->getTimestamp() - time());
|
||||
$this->logger->debug("passkey begin rate-limited: {$request->getClientIp()}");
|
||||
|
||||
$response = $this->error('Too many passkey attempts, please slow down.', $request);
|
||||
$response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS);
|
||||
$response->headers->set('Retry-After', (string) $retryAfter);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed ceremony is indistinguishable from a wrong TOTP code, and spends
|
||||
* the same shared budget — including the same 418/429 outcome when exhausted.
|
||||
*/
|
||||
private function failure(Request $request): Response
|
||||
{
|
||||
$limited = $this->loginLimiter
|
||||
->create((string) $request->getClientIp())
|
||||
->consume(1)
|
||||
->getRemainingTokens() < 1;
|
||||
|
||||
$this->logger->debug("passkey ceremony failed for: {$request->getClientIp()}");
|
||||
|
||||
if ($limited) {
|
||||
$response = $this->error(
|
||||
$this->config->teapot() ? $this->config->teapotTitle() : $this->config->tooManyTitle(),
|
||||
$request,
|
||||
);
|
||||
$response->setStatusCode(
|
||||
$this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response = $this->error($this->config->errorMessage(), $request);
|
||||
$response->setStatusCode(Response::HTTP_UNAUTHORIZED);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a reply as ceremony output so the no-store policy can find it.
|
||||
*
|
||||
* These are the only browser-facing 2xx responses this application produces,
|
||||
* and `SecurityHeadersListener` otherwise assumes any 2xx is consumed by
|
||||
* `forward_auth` and leaves it cacheable.
|
||||
*/
|
||||
private function ceremonyResponse(Response $response): Response
|
||||
{
|
||||
$response->headers->set(self::CEREMONY_MARKER, '1');
|
||||
$response->headers->set('Content-Type', 'application/json');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON error carrying a fresh nonce and the same shape the login page
|
||||
* expects, so the caller can fall back to the TOTP form without a reload.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function error(string $message, Request $request): Response
|
||||
{
|
||||
return new Response(
|
||||
(string) json_encode([
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $request->getHost(),
|
||||
'username' => '',
|
||||
]),
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function json(array $payload): Response
|
||||
{
|
||||
return new Response((string) json_encode($payload), Response::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function body(Request $request): array
|
||||
{
|
||||
$raw = $request->getContent();
|
||||
if ('' === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
@@ -25,8 +25,8 @@ final readonly class RejectListener
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
@@ -43,9 +43,9 @@ final readonly class RejectListener
|
||||
$html = $this->twig->render('error.html.twig');
|
||||
$event->setResponse(new Response(
|
||||
$html,
|
||||
($this->config->teapot()
|
||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
||||
['Content-Type' => 'text/html']
|
||||
$this->config->teapot()
|
||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
|
||||
['Content-Type' => 'text/html'],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
|
||||
/**
|
||||
@@ -15,15 +18,21 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
*/
|
||||
final readonly class SecurityHeadersListener
|
||||
{
|
||||
public function __construct(
|
||||
private DomainInterface $domainManager,
|
||||
private PasskeyPolicyInterface $passkeyPolicy,
|
||||
) {
|
||||
}
|
||||
|
||||
#[AsEventListener(priority: 0)]
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (! $event->isMainRequest()) {
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $event->getResponse();
|
||||
$headers = $response->headers;
|
||||
$headers = $response->headers;
|
||||
|
||||
/* prevent MIME-type sniffing */
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
@@ -36,13 +45,71 @@ final readonly class SecurityHeadersListener
|
||||
|
||||
/* 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. */
|
||||
$headers->set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src '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';";
|
||||
|
||||
/* `publickey-credentials-get`/`-create` do NOT fall back to default-src,
|
||||
* so without these directives the browser refuses the ceremony even
|
||||
* though the script itself is allowed to run. `connect-src 'self'` is
|
||||
* needed in both modes here, because the passkey flow always talks to
|
||||
* the server with fetch(). */
|
||||
if ($this->passkeyPolicy->isAvailableFor($event->getRequest())) {
|
||||
$csp .= " connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';";
|
||||
} elseif ($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.
|
||||
*
|
||||
* A ceremony reply is the exception that proves the rule: it is a 2xx
|
||||
* that goes straight to the browser, because the auth subdomain has no
|
||||
* forward_auth in front of it. Left alone it would be cacheable, so a
|
||||
* browser could replay a stale challenge. `PasskeyListener` marks those
|
||||
* responses and the marker is stripped here. */
|
||||
if (!$response->isSuccessful()) {
|
||||
$this->applyNoStore($headers);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($headers->has(PasskeyListener::CEREMONY_MARKER)) {
|
||||
$headers->remove(PasskeyListener::CEREMONY_MARKER);
|
||||
$this->applyNoStore($headers);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyNoStore(ResponseHeaderBag $headers): void
|
||||
{
|
||||
$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', '*');
|
||||
}
|
||||
}
|
||||
|
||||
+16
-14
@@ -26,7 +26,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
$this->cache = $cache;
|
||||
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
||||
foreach ($items as $item) {
|
||||
if (! $item->isHit()) {
|
||||
if (!$item->isHit()) {
|
||||
$this->initialize();
|
||||
break;
|
||||
}
|
||||
@@ -49,6 +49,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
public function getKeys(): array
|
||||
{
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
|
||||
return array_keys($keyList->get() ?? []);
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
public function getChanges(): array
|
||||
{
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
|
||||
return $changeList->get() ?? [];
|
||||
}
|
||||
|
||||
@@ -90,12 +92,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
public function clear(): bool
|
||||
{
|
||||
/* only bother clearing the pool if it is not empty */
|
||||
if (! empty($this->getKeys())) {
|
||||
if (!empty($this->getKeys())) {
|
||||
$response = $this->cache->clear();
|
||||
|
||||
$this->initialize();
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -109,7 +113,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
unset($keyValues[$key]);
|
||||
$keyList->set($keyValues);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
||||
$this->logChange($key, self::REMOVED);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
foreach ($keys as $key) {
|
||||
if (isset($keyValues[$key])) {
|
||||
unset($keyValues[$key]);
|
||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
||||
$this->logChange($key, self::REMOVED);
|
||||
}
|
||||
}
|
||||
$keyList->set($keyValues);
|
||||
@@ -139,6 +143,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
public function save(CacheItemInterface $item): bool
|
||||
{
|
||||
$this->update($item);
|
||||
|
||||
return $this->cache->save($item);
|
||||
}
|
||||
|
||||
@@ -146,6 +151,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
public function saveDeferred(CacheItemInterface $item): bool
|
||||
{
|
||||
$this->update($item);
|
||||
|
||||
return $this->cache->saveDeferred($item);
|
||||
}
|
||||
|
||||
@@ -171,27 +177,23 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
/** @throws OutOfBoundsException */
|
||||
private function isValid(string $key): void
|
||||
{
|
||||
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not modify the private key or change lists'
|
||||
);
|
||||
if (self::KEY_LIST === $key || self::CHANGE_LIST === $key) {
|
||||
throw new OutOfBoundsException('Can not modify the private key or change lists');
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws OutOfBoundsException */
|
||||
private function allValid(array $keys): void
|
||||
{
|
||||
if (in_array(self::KEY_LIST, $keys, true) ||
|
||||
in_array(self::CHANGE_LIST, $keys, true)
|
||||
if (\in_array(self::KEY_LIST, $keys, true)
|
||||
|| \in_array(self::CHANGE_LIST, $keys, true)
|
||||
) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not modify the private key or change lists'
|
||||
);
|
||||
throw new OutOfBoundsException('Can not modify the private key or change lists');
|
||||
}
|
||||
}
|
||||
|
||||
/** @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);
|
||||
$changeValues = $changeList->get();
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
|
||||
/* need autoconfigure so we get it from the service container in Kernel->boot() */
|
||||
#[Autoconfigure(public: true)]
|
||||
@@ -17,10 +18,10 @@ final readonly class PersistCache
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
CacheItemPoolInterface $sessionStorage,
|
||||
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||
#[Target('sessionStorage')] CacheItemPoolInterface $sessionStorage,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,18 +11,22 @@ use Psr\Cache\InvalidArgumentException;
|
||||
* they are single-use and marked as used after successful authentication */
|
||||
interface BackupCodeInterface
|
||||
{
|
||||
/** 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
|
||||
*
|
||||
* @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
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ namespace App\Service;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
|
||||
/** backup-codes are case‑insensitive alphanumeric strings
|
||||
* they are single-use and marked as used after successful authentication */
|
||||
@@ -29,27 +30,31 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
private CacheItemPoolInterface $sessionCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(CacheItemPoolInterface $sessionCache)
|
||||
{
|
||||
public function __construct(
|
||||
#[Target('sessionCache')] CacheItemPoolInterface $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
|
||||
*
|
||||
* @return string[] Generated backup codes
|
||||
*
|
||||
* @throws InvalidArgumentException|Exception */
|
||||
public function generate(int $count = self::DEFAULT_COUNT): array
|
||||
{
|
||||
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
for ($i = 0; $i < $count; ++$i) {
|
||||
/* output is alphanumeric string of given length */
|
||||
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
|
||||
random_bytes($length)
|
||||
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
|
||||
random_bytes($length),
|
||||
), 16, 36), 0, $length), $length, '0', \STR_PAD_LEFT));
|
||||
}
|
||||
$this->saveCodes($codes);
|
||||
$this->logger->info("generated {$count} backup codes");
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
@@ -62,35 +67,38 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
$itemsToRemove[] = $key;
|
||||
}
|
||||
}
|
||||
if (count($itemsToRemove) > 0) {
|
||||
if (\count($itemsToRemove) > 0) {
|
||||
$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
|
||||
*
|
||||
* @return bool true if the code is valid and unused
|
||||
*
|
||||
* @throws InvalidArgumentException */
|
||||
public function verifyAndConsume(string $code): bool
|
||||
{
|
||||
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
|
||||
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
|
||||
$backupKey = 'backup_'.preg_replace('/[^a-z0-9]+/', '', strtolower($code));
|
||||
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
|
||||
$this->logger->debug('checking backup code: ' . ($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()) {
|
||||
$this->logger->debug("valid backup code");
|
||||
$this->logger->debug('valid backup code');
|
||||
/* mark backup code as spent */
|
||||
$backupItem->set(false); /* used */
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
AppConstants::FAR_FUTURE_DATE
|
||||
AppConstants::FAR_FUTURE_DATE,
|
||||
));
|
||||
$this->sessionCache->save($backupItem);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -105,7 +113,7 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
AppConstants::FAR_FUTURE_DATE
|
||||
AppConstants::FAR_FUTURE_DATE,
|
||||
));
|
||||
$this->sessionCache->saveDeferred($backupItem);
|
||||
}
|
||||
|
||||
@@ -6,21 +6,21 @@ namespace App\Service;
|
||||
|
||||
interface DomainInterface
|
||||
{
|
||||
/** 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 */
|
||||
public function getAuthSubdomain(): ?string;
|
||||
|
||||
/** 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
|
||||
*
|
||||
* @return bool Returns true if it is acceptable to send the user there */
|
||||
public function validReturn(string $url): bool;
|
||||
|
||||
/** check if host-base matches auth-base
|
||||
* @param string $host
|
||||
/** 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"
|
||||
/** 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;
|
||||
}
|
||||
|
||||
+121
-112
@@ -10,100 +10,100 @@ final readonly class DomainManager implements DomainInterface
|
||||
{
|
||||
/* top-level-domains which are known to have multiple parts */
|
||||
private const array TLD = [
|
||||
'ai' => ['com','net','off','org'],
|
||||
'am' => ['radio'],
|
||||
'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'],
|
||||
'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'],
|
||||
'fr' => ['com','nom','tm','asso','gouv','pol'],
|
||||
'ge' => ['com','net','org','edu','gov','mil'],
|
||||
'gg' => ['co','net','org'],
|
||||
'gr' => ['com','net','org','gov','edu','mil'],
|
||||
'hk' => ['com','net','org','gov','edu','idv'],
|
||||
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'],
|
||||
'id' => ['ac','biz','co','desa','go','mil','my','net','or','sch','web'],
|
||||
'ie' => ['gov'],
|
||||
'il' => ['ac','co','gov','idf','k12','muni','net','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'],
|
||||
'ai' => ['com', 'net', 'off', 'org'],
|
||||
'am' => ['radio'],
|
||||
'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'],
|
||||
'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'],
|
||||
'fr' => ['com', 'nom', 'tm', 'asso', 'gouv', 'pol'],
|
||||
'ge' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
|
||||
'gg' => ['co', 'net', 'org'],
|
||||
'gr' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||
'hk' => ['com', 'net', 'org', 'gov', 'edu', 'idv'],
|
||||
'hu' => ['co', '2000', 'privat', 'sport', 'tm', 'erotica', 'sex', 'video', 'info', 'org', 'net', 'gov', 'edu', 'mil', 'press', 'biz'],
|
||||
'id' => ['ac', 'biz', 'co', 'desa', 'go', 'mil', 'my', 'net', 'or', 'sch', 'web'],
|
||||
'ie' => ['gov'],
|
||||
'il' => ['ac', 'co', 'gov', 'idf', 'k12', 'muni', 'net', '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;
|
||||
@@ -111,38 +111,41 @@ final readonly class DomainManager implements DomainInterface
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
|
||||
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
|
||||
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
|
||||
) {
|
||||
$this->subdomainRedirect = $subdomainRedirect;
|
||||
$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 */
|
||||
public function getAuthSubdomain(): ?string
|
||||
{
|
||||
if ($this->authBase()) {
|
||||
return $this->authSubdomain;
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
* @return bool Returns true if it is acceptable to send the user there */
|
||||
public function validReturn(string $url): bool
|
||||
{
|
||||
/* ensure url is valid and, when using an auth subdomain,
|
||||
* that the url host matches the base domain */
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
if (!filter_var($url, \FILTER_VALIDATE_URL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->authBase()) {
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === null || $host === false || $host === '') {
|
||||
$host = parse_url($url, \PHP_URL_HOST);
|
||||
if (null === $host || false === $host || '' === $host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* do not send the user to another domain */
|
||||
return $this->matchesAuth($host);
|
||||
}
|
||||
@@ -150,58 +153,64 @@ final readonly class DomainManager implements DomainInterface
|
||||
return true;
|
||||
}
|
||||
|
||||
/** check if host-base matches auth-base
|
||||
* @param string $host
|
||||
/** 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
|
||||
{
|
||||
$hostBase = $this->baseDomain($host);
|
||||
$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 */
|
||||
public function authBase(): ?string
|
||||
{
|
||||
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
|
||||
return $this->baseDomain($this->authSubdomain);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** this lets us determine the base domain of the given ip, localhost, or domain
|
||||
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
||||
* things like "localhost" and "8.8.8.8" will return null
|
||||
* things like "localhost" and "8.8.8.8" will return null.
|
||||
*
|
||||
* @param string $host ip, localhost, or domain with zero or more subdomains
|
||||
*
|
||||
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
|
||||
private function baseDomain(string $host): ?string
|
||||
{
|
||||
/* if host is an ip address (or localhost), leave it as is */
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
|
||||
if (filter_var($host, \FILTER_VALIDATE_IP) || 'localhost' === $host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = explode('.', strtolower($host));
|
||||
$keep = $this->baseLength($parts);
|
||||
$parts = array_slice($parts, -$keep);
|
||||
$parts = \array_slice($parts, -$keep);
|
||||
|
||||
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
|
||||
*
|
||||
* @return int typically 2 but sometimes 3 */
|
||||
private function baseLength(array $parts): int
|
||||
{
|
||||
$length = count($parts);
|
||||
$length = \count($parts);
|
||||
$baseLength = min(2, $length);
|
||||
/* check if host should retain 3 parts, due to TLD */
|
||||
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
|
||||
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
|
||||
if (\count($parts) > 2 && isset(self::TLD[$parts[$length - 1]])
|
||||
&& \in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
|
||||
) {
|
||||
$baseLength = min(3, $length);
|
||||
}
|
||||
|
||||
return $baseLength;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-106
@@ -6,141 +6,70 @@ namespace App\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Override;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
/**
|
||||
* Authenticates a TOTP code (or backup code) and, on success, grants access.
|
||||
*
|
||||
* The "grant access" half now lives in {@see SessionIssuer} so the passkey
|
||||
* ceremony produces an identical response. This class keeps the part that is
|
||||
* genuinely specific to code-based login: verifying the code and enforcing the
|
||||
* single-use nonce.
|
||||
*/
|
||||
final readonly class LoginManager implements LoginInterface
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use GetTotpTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
|
||||
private CacheItemPoolInterface $sessionCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
private BackupCodeInterface $backupCodeManager,
|
||||
private DomainInterface $domainManager,
|
||||
private BackupCodeInterface $backupCodeManager,
|
||||
private SessionIssuerInterface $sessionIssuer,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function checkToken(Payload $payload, Request $request): ?Response
|
||||
{
|
||||
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
|
||||
if (Scope::Ip === $payload->scope && !$this->config->ipTtl()) {
|
||||
/* requested to grant ip access, but that is not enabled */
|
||||
$payload->scope = Scope::Cookie;
|
||||
}
|
||||
|
||||
if ($this->getTotp()->verify($payload->token, null, 1) ||
|
||||
$this->backupCodeManager->verifyAndConsume($payload->token)
|
||||
if (!$this->getTotp()->verify($payload->token, null, 1)
|
||||
&& !$this->backupCodeManager->verifyAndConsume($payload->token)
|
||||
) {
|
||||
/* token is correct (TOTP or Backup) */
|
||||
|
||||
/* if server nonce is found and is valid */
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
|
||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */
|
||||
$this->nonceCache->save($nonceItem);
|
||||
|
||||
/* token authentication successful, grant access and set response */
|
||||
$cleanId = $this->makeCacheKey($payload->id);
|
||||
|
||||
/* if they just want this one page, return ok, to grant them access */
|
||||
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||
|
||||
if ($payload->scope !== Scope::None) {
|
||||
/* grant access based on the requested scope */
|
||||
if ($payload->scope === Scope::Cookie) {
|
||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||
} elseif ($payload->scope === Scope::Ip) {
|
||||
$this->setIp($cleanId, $request->getClientIp());
|
||||
}
|
||||
|
||||
if ($payload->json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode([
|
||||
'message' => 'Login successful',
|
||||
'nonce' => null,
|
||||
]);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = "hi $cleanId, please reload";
|
||||
}
|
||||
|
||||
$location = $request->query->has('return') &&
|
||||
$this->domainManager->validReturn($request->query->get('return')) ?
|
||||
"{$request->query->get('return')}" :
|
||||
"{$request->getPathInfo()}{$request->getQueryString()}";
|
||||
|
||||
/* force redirect to use GET method (important when using central auth) */
|
||||
$response->setContent($content)
|
||||
->setStatusCode(Response::HTTP_SEE_OTHER)
|
||||
->headers->set('Location', $location);
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
}
|
||||
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
return $response;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setCookie(string $id, string $host): Cookie
|
||||
{
|
||||
/* successful auth with token, store session and set the cookie */
|
||||
$ulid = new Ulid();
|
||||
$sessionCookie = $this->sessionCache->getItem(
|
||||
$this->makeCacheKey("cookie_$ulid")
|
||||
);
|
||||
if ($sessionCookie->isHit()) {
|
||||
/* it is supposed to be impossible to have collisions */
|
||||
$this->logger->error("aborting: ULID collision");
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
/* token is correct (TOTP or Backup) */
|
||||
|
||||
/* if server nonce is found and is valid */
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
|
||||
if (!$nonceItem->isHit() || !$nonceItem->get()) {
|
||||
return null;
|
||||
}
|
||||
$sessionCookie->set($id);
|
||||
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
||||
$this->sessionCache->save($sessionCookie);
|
||||
|
||||
return Cookie::create(
|
||||
name: $this->sessionCookieName($this->domainManager),
|
||||
value: $ulid->toString(),
|
||||
expire: time() + $this->config->cookieTtl(),
|
||||
path: '/',
|
||||
domain: $this->sessionCookieDomain($this->domainManager, $host),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: Cookie::SAMESITE_STRICT,
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
|
||||
$this->nonceCache->save($nonceItem);
|
||||
|
||||
return $this->sessionIssuer->issue(
|
||||
$payload->id,
|
||||
$payload->scope,
|
||||
$request,
|
||||
$payload->json,
|
||||
);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setIp(string $id, string $ip): void
|
||||
{
|
||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||
|
||||
$sessionIp = $this->sessionCache->getItem($ipKey);
|
||||
$sessionIp->set($id);
|
||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
||||
$this->sessionCache->save($sessionIp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Cose\Algorithm\Manager;
|
||||
use Cose\Algorithm\Signature\ECDSA\ES256;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Throwable;
|
||||
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
||||
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
|
||||
use Webauthn\AuthenticatorAssertionResponseValidator;
|
||||
use Webauthn\AuthenticatorAttestationResponseValidator;
|
||||
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\Denormalizer\WebauthnSerializerFactory;
|
||||
use Webauthn\Exception\InvalidDataException;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
|
||||
/**
|
||||
* Builds the WebAuthn collaborators, and is the one place that knows how they
|
||||
* are wired.
|
||||
*
|
||||
* Everything that touches webauthn-lib types goes through here, so a library
|
||||
* major version that renames or moves those types is a single-file change
|
||||
* instead of a hunt through the codebase.
|
||||
*
|
||||
* **Attestation is deliberately `none`.** The alternatives were measured and
|
||||
* rejected: attestation conveyance is only a preference a client may ignore,
|
||||
* and the FIDO metadata service is bypassed both by the zero AAGUID that
|
||||
* privacy-preserving passkeys already send and by self attestation — while
|
||||
* still refusing legitimate authenticators that postdate its cached blob. See
|
||||
* `docs/passkey-auth-subdomain-plan.md` §2.3 for the evidence, and `SECURITY.md`
|
||||
* for the conditions that would justify revisiting it.
|
||||
*/
|
||||
final readonly class PasskeyCeremonyFactory
|
||||
{
|
||||
private AttestationStatementSupportManager $attestationStatementSupportManager;
|
||||
|
||||
private PasskeyCounterChecker $counterChecker;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$manager = AttestationStatementSupportManager::create();
|
||||
$manager->add(NoneAttestationStatementSupport::create());
|
||||
$this->attestationStatementSupportManager = $manager;
|
||||
$this->counterChecker = new PasskeyCounterChecker();
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire form of the ceremony options, ready to JSON-encode for the client.
|
||||
*
|
||||
* Goes through the serializer rather than `json_encode()`, because the
|
||||
* challenge is raw binary: `json_encode()` rejects it outright, and the
|
||||
* serializer base64url-encodes exactly the fields the browser expects. Using
|
||||
* one path here and another at verification time is how a challenge silently
|
||||
* stops matching, so both go through this factory.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function optionsAsArray(PublicKeyCredentialCreationOptions|PublicKeyCredentialRequestOptions $options): array
|
||||
{
|
||||
return $this->serializer()->normalize($options, 'json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator for the registration ceremony.
|
||||
*
|
||||
* The origins are passed in rather than read from a request, so the scheme
|
||||
* and host can only ever come from configuration. This is what makes D4
|
||||
* enforceable: an `http://` origin is never presented to the library as
|
||||
* acceptable, no matter how the request arrived at the container.
|
||||
*
|
||||
* @param string[] $allowedOrigins
|
||||
*/
|
||||
public function creationCeremonyValidator(array $allowedOrigins): AuthenticatorAttestationResponseValidator
|
||||
{
|
||||
return AuthenticatorAttestationResponseValidator::create(
|
||||
$this->ceremonyStepManagerFactory($allowedOrigins)->creationCeremony(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator for the login (assertion) ceremony.
|
||||
*
|
||||
* @param string[] $allowedOrigins
|
||||
*/
|
||||
public function requestCeremonyValidator(array $allowedOrigins): AuthenticatorAssertionResponseValidator
|
||||
{
|
||||
return AuthenticatorAssertionResponseValidator::create(
|
||||
$this->ceremonyStepManagerFactory($allowedOrigins)->requestCeremony(),
|
||||
);
|
||||
}
|
||||
|
||||
public function counterChecker(): PasskeyCounterChecker
|
||||
{
|
||||
return $this->counterChecker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony steps shared by both ceremonies.
|
||||
*
|
||||
* `setSecuredRelyingPartyId()` is deliberately never called: it is deprecated
|
||||
* in 5.2 and, more importantly, it is the escape hatch that would let an
|
||||
* `http://` origin through. Development uses real TLS instead (D4).
|
||||
*
|
||||
* @param string[] $allowedOrigins
|
||||
*/
|
||||
private function ceremonyStepManagerFactory(array $allowedOrigins): CeremonyStepManagerFactory
|
||||
{
|
||||
$factory = new CeremonyStepManagerFactory();
|
||||
$factory->setAllowedOrigins($allowedOrigins);
|
||||
$factory->setAlgorithmManager(Manager::create()->add(ES256::create()));
|
||||
$factory->setAttestationStatementSupportManager($this->attestationStatementSupportManager);
|
||||
/* replace the library default, which rejects the constant-zero counter
|
||||
* that synchronised passkeys report — see PasskeyCounterChecker */
|
||||
$factory->setCounterChecker($this->counterChecker);
|
||||
|
||||
return $factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* The serializer for every WebAuthn value: credential records, ceremony
|
||||
* options, and the client's response.
|
||||
*
|
||||
* This must be used instead of `json_encode()`. Options carry raw binary
|
||||
* (the challenge) which `json_encode()` rejects outright, and
|
||||
* `CredentialRecord` is not `JsonSerializable` at all — the serializer
|
||||
* base64url-encodes those fields and is required for a correct round-trip.
|
||||
*
|
||||
* The concrete `Serializer` is returned because the library's own return
|
||||
* type (`SerializerInterface`) only declares `serialize()`/`deserialize()`,
|
||||
* while this class also needs `normalize()`/`denormalize()`.
|
||||
*/
|
||||
public function serializer(): Serializer
|
||||
{
|
||||
$serializer = (new WebauthnSerializerFactory($this->attestationStatementSupportManager))->create();
|
||||
|
||||
/* the library's declared return type is the narrower interface, so this
|
||||
* narrows it back — failing loudly if a future version ever returns
|
||||
* something else, rather than erroring at the first ceremony */
|
||||
if (!$serializer instanceof Serializer) {
|
||||
throw new RuntimeException('Expected the WebAuthn serializer to be a '.Serializer::class.'.');
|
||||
}
|
||||
|
||||
return $serializer;
|
||||
}
|
||||
|
||||
public function attestationStatementSupportManager(): AttestationStatementSupportManager
|
||||
{
|
||||
return $this->attestationStatementSupportManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a credential record for storage.
|
||||
*
|
||||
* @throws InvalidDataException
|
||||
*/
|
||||
public function serializeCredential(CredentialRecord $record): string
|
||||
{
|
||||
return $this->serializer()->serialize($record, 'json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a credential record previously written by {@see serializeCredential()}.
|
||||
*
|
||||
* Returns null rather than throwing when the stored payload is unusable: a
|
||||
* corrupt entry must degrade to "this passkey is unavailable", never to a
|
||||
* 500 on the login page.
|
||||
*/
|
||||
public function deserializeCredential(string $json): ?CredentialRecord
|
||||
{
|
||||
try {
|
||||
return $this->serializer()->deserialize($json, CredentialRecord::class, 'json');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Trait\StringTrait;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
|
||||
/**
|
||||
* Short-lived server-side state for an in-flight WebAuthn ceremony.
|
||||
*
|
||||
* **The client's copy of the challenge is never trusted.** Starting a ceremony
|
||||
* issues the challenge *and* a separate opaque `ceremonyId`; only the
|
||||
* server-side record keyed by that id is authoritative at `finish`. A client
|
||||
* that swaps in its own challenge is therefore comparing against a value the
|
||||
* server never issued.
|
||||
*
|
||||
* **Single-use.** {@see consume()} deletes the record *before* the caller
|
||||
* verifies anything, so a failed or replayed `finish` cannot be retried against
|
||||
* the same challenge.
|
||||
*
|
||||
* **Deliberately not persisted.** This uses the `nonceCache` pool, which is APCu
|
||||
* and stays in memory. A ceremony that does not complete within its TTL *should*
|
||||
* evaporate — persisting it would only widen the replay window across restarts.
|
||||
*/
|
||||
final readonly class PasskeyCeremonyStore implements PasskeyCeremonyStoreInterface
|
||||
{
|
||||
use StringTrait;
|
||||
|
||||
private const string PREFIX = 'passkey_cer_';
|
||||
|
||||
/** Ceremonies live 5 minutes: a user has to interact with a biometric prompt. */
|
||||
public const int CEREMONY_TTL = 300;
|
||||
|
||||
/** 15 bytes fits neatly into a base64url string without padding. */
|
||||
private const int CEREMONY_ID_BYTES = 15;
|
||||
|
||||
/** 32-byte challenge, per WebAuthn's recommendation. */
|
||||
private const int CHALLENGE_BYTES = 32;
|
||||
|
||||
public function __construct(
|
||||
#[Target('nonceCache')] private CacheItemPoolInterface $nonceCache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ceremonyId: string, challenge: string}
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function startLogin(): array
|
||||
{
|
||||
/* @var array{ceremonyId: string, challenge: string} */
|
||||
return $this->store(PasskeyCeremonyStoreInterface::TYPE_LOGIN, [
|
||||
'challenge' => random_bytes(self::CHALLENGE_BYTES),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ceremonyId: string, challenge: string, userHandle: string}
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function startRegistration(string $identity): array
|
||||
{
|
||||
/* @var array{ceremonyId: string, challenge: string, userHandle: string} */
|
||||
return $this->store(PasskeyCeremonyStoreInterface::TYPE_REGISTER, [
|
||||
'challenge' => random_bytes(self::CHALLENGE_BYTES),
|
||||
'identity' => $identity,
|
||||
/* Derived from the identity rather than taken from the client, so a
|
||||
* caller cannot register a credential against an identity it did not
|
||||
* authenticate as. Fixed length, stable per identity, and it does not
|
||||
* leak the label into the authenticator. */
|
||||
'userHandle' => hash('sha256', $identity, true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and destroy a ceremony, returning its record.
|
||||
*
|
||||
* Returns null when the id is unknown, expired, already consumed, or was
|
||||
* started for the other ceremony type. The caller cannot distinguish these
|
||||
* cases, and does not need to.
|
||||
*
|
||||
* @return array{challenge: string, identity?: string, userHandle?: string}|null
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function consume(string $ceremonyId, string $expectedType): ?array
|
||||
{
|
||||
$key = $this->key($ceremonyId);
|
||||
$item = $this->nonceCache->getItem($key);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $item->get();
|
||||
|
||||
/* delete before validating: a malformed or replayed record must not be
|
||||
* usable again even if the checks below reject it */
|
||||
$this->nonceCache->deleteItem($key);
|
||||
|
||||
if (!\is_array($payload)
|
||||
|| ($payload['type'] ?? null) !== $expectedType
|
||||
|| !isset($payload['challenge'])
|
||||
|| !\is_string($payload['challenge'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{challenge: string, identity?: string, userHandle?: string} $payload */
|
||||
$payload = array_filter(
|
||||
$payload,
|
||||
static fn (string $key): bool => \in_array($key, ['challenge', 'identity', 'userHandle'], true),
|
||||
\ARRAY_FILTER_USE_KEY,
|
||||
);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $payload
|
||||
*
|
||||
* @return array<string,string>
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function store(string $type, array $payload): array
|
||||
{
|
||||
$ceremonyId = rtrim(strtr(base64_encode(random_bytes(self::CEREMONY_ID_BYTES)), '+/', '-_'), '=');
|
||||
|
||||
$item = $this->nonceCache->getItem($this->key($ceremonyId));
|
||||
$item->set([
|
||||
'type' => $type,
|
||||
...$payload,
|
||||
'createdAt' => new DateTimeImmutable()->format(\DATE_ATOM),
|
||||
]);
|
||||
$item->expiresAfter(self::CEREMONY_TTL);
|
||||
$this->nonceCache->save($item);
|
||||
|
||||
return ['ceremonyId' => $ceremonyId, ...$payload];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony id is hashed rather than passed through `makeCacheKey()`,
|
||||
* because that sanitises the base64url alphabet into `_` and is therefore
|
||||
* not injective — two distinct ids could collide on one cache slot.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function key(string $ceremonyId): string
|
||||
{
|
||||
return $this->makeCacheKey(self::PREFIX.hash('sha256', $ceremonyId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Server-side storage for in-flight WebAuthn ceremonies.
|
||||
*
|
||||
* Kept separate from {@see PasskeyManager} so the security-critical properties —
|
||||
* the challenge is server-authoritative and single-use — are testable without
|
||||
* constructing a real authenticator response.
|
||||
*/
|
||||
interface PasskeyCeremonyStoreInterface
|
||||
{
|
||||
public const string TYPE_LOGIN = 'login';
|
||||
|
||||
public const string TYPE_REGISTER = 'register';
|
||||
|
||||
/**
|
||||
* Start a login (assertion) ceremony.
|
||||
*
|
||||
* @return array{ceremonyId: string, challenge: string}
|
||||
*/
|
||||
public function startLogin(): array;
|
||||
|
||||
/**
|
||||
* Start a registration (attestation) ceremony for an already-authenticated
|
||||
* identity.
|
||||
*
|
||||
* @return array{ceremonyId: string, challenge: string, userHandle: string}
|
||||
*/
|
||||
public function startRegistration(string $identity): array;
|
||||
|
||||
/**
|
||||
* Read and destroy a ceremony.
|
||||
*
|
||||
* Returns null when the id is unknown, expired, already consumed, or was
|
||||
* started for the other ceremony type.
|
||||
*
|
||||
* @return array{challenge: string, identity?: string, userHandle?: string}|null
|
||||
*/
|
||||
public function consume(string $ceremonyId, string $expectedType): ?array;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Override;
|
||||
use Webauthn\Counter\CounterChecker;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\Exception\CounterException;
|
||||
|
||||
/**
|
||||
* Signature-counter policy for passkey assertions.
|
||||
*
|
||||
* **Why this replaces the library default.** `ThrowExceptionIfInvalid` (the
|
||||
* default wired up by `CeremonyStepManagerFactory`) requires the reported
|
||||
* counter to be *strictly greater* than the stored one. That is wrong for the
|
||||
* passkeys this feature targets: platform passkeys are synchronised through the
|
||||
* OS keychain, and a synchronised authenticator reports a constant `0` forever
|
||||
* (the spec permits it, and the multi-device design effectively requires it).
|
||||
* Measured against the installed library:
|
||||
*
|
||||
* stored 0, reported 0 -> `ThrowExceptionIfInvalid` throws `CounterException`
|
||||
*
|
||||
* so with the default checker a brand-new synced passkey fails on its *first*
|
||||
* login — and only on real hardware, never in a unit test that increments the
|
||||
* counter. That is the worst possible failure shape, so the default is not used.
|
||||
*
|
||||
* **What is kept.** A counter that goes *backwards* still fails. That is the one
|
||||
* signal the counter can carry (a cloned authenticator replaying an older
|
||||
* assertion), and rejecting it costs nothing because a genuine synchronised
|
||||
* passkey only ever reports the same value or a larger one.
|
||||
*
|
||||
* Note this is defence in depth and not relied upon for security: a
|
||||
* synchronised passkey's counter carries no clone signal at all, which is why
|
||||
* `SECURITY.md` records that clone detection is explicitly not a property this
|
||||
* feature claims. The real protections are per-credential challenge binding,
|
||||
* origin/RP-ID checks, and the user-verification requirement.
|
||||
*/
|
||||
final readonly class PasskeyCounterChecker implements CounterChecker
|
||||
{
|
||||
/**
|
||||
* @throws CounterException when the reported counter moves backwards
|
||||
*/
|
||||
#[Override]
|
||||
public function check(CredentialRecord $credentialRecord, int $currentCounter): void
|
||||
{
|
||||
if ($currentCounter < $credentialRecord->counter) {
|
||||
throw CounterException::create($currentCounter, $credentialRecord->counter, 'The signature counter moved backwards, which can indicate a cloned authenticator.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\StringTrait;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Webauthn\CredentialRecord;
|
||||
|
||||
/**
|
||||
* @see PasskeyCredentialStoreInterface
|
||||
*
|
||||
* **Persistence.** The pool is wrapped in a {@see MonitorCacheKeys}, exactly
|
||||
* as `LoginManager` and `BackupCodeManager` do. Without that wrapper the
|
||||
* credentials would live only in the APCu-side pool and disappear on the next
|
||||
* container restart, because `PersistCache::persist()` only flushes keys that a
|
||||
* monitor recorded. `PasskeyCredentialStoreTest` asserts visibility in the
|
||||
* underlying persistent pool, not just the wrapped one.
|
||||
*
|
||||
* **Layout.**
|
||||
* passkey_cred_<key(credentialId)> -> serialized credential + metadata
|
||||
* passkey_index -> credentialId => {identity, label, createdAt}
|
||||
*
|
||||
* The index exists so the login page can build `allowCredentials` without
|
||||
* enumerating the whole key space, and so a corrupt credential cannot make the
|
||||
* list disappear entirely.
|
||||
*/
|
||||
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
|
||||
{
|
||||
use StringTrait;
|
||||
|
||||
private const string PREFIX = 'passkey_cred_';
|
||||
private const string INDEX_KEY = 'passkey_index';
|
||||
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||
private PasskeyCeremonyFactory $ceremonyFactory,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function all(): array
|
||||
{
|
||||
$credentials = [];
|
||||
foreach ($this->credentialIds() as $credentialId) {
|
||||
$credential = $this->find($credentialId);
|
||||
if (null !== $credential) {
|
||||
$credentials[] = $credential;
|
||||
}
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function find(string $credentialId): ?PasskeyCredential
|
||||
{
|
||||
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $item->get();
|
||||
if (!\is_array($payload)
|
||||
|| !isset($payload['record'], $payload['identity'], $payload['label'], $payload['createdAt'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->ceremonyFactory->deserializeCredential((string) $payload['record']);
|
||||
if (null === $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Guard against an index/record mismatch: the stored record decides which
|
||||
* credential id it answers to. */
|
||||
if (!hash_equals($record->publicKeyCredentialId, $credentialId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasskeyCredential(
|
||||
$record,
|
||||
(string) $payload['identity'],
|
||||
(string) $payload['label'],
|
||||
new DateTimeImmutable((string) $payload['createdAt']),
|
||||
isset($payload['lastUsedAt']) ? new DateTimeImmutable((string) $payload['lastUsedAt']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function findByIdentity(string $identity): array
|
||||
{
|
||||
$matches = [];
|
||||
foreach ($this->all() as $credential) {
|
||||
if (hash_equals($credential->identity, $identity)) {
|
||||
$matches[] = $credential;
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function save(PasskeyCredential $credential): void
|
||||
{
|
||||
$credentialId = $credential->record->publicKeyCredentialId;
|
||||
|
||||
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
|
||||
$item->set([
|
||||
'record' => $this->ceremonyFactory->serializeCredential($credential->record),
|
||||
'identity' => $credential->identity,
|
||||
'label' => $credential->label,
|
||||
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
|
||||
'lastUsedAt' => $credential->lastUsedAt?->format(\DATE_ATOM),
|
||||
]);
|
||||
/* 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 */
|
||||
$item->expiresAt($this->forever());
|
||||
$this->sessionCache->save($item);
|
||||
|
||||
$this->writeIndexEntry($credentialId, [
|
||||
'identity' => $credential->identity,
|
||||
'label' => $credential->label,
|
||||
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function updateUsage(CredentialRecord $record): void
|
||||
{
|
||||
$existing = $this->find($record->publicKeyCredentialId);
|
||||
if (null === $existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->save($existing->withUsage($record, new DateTimeImmutable()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function remove(string $credentialId): bool
|
||||
{
|
||||
$index = $this->readIndex();
|
||||
$existed = \array_key_exists($credentialId, $index);
|
||||
|
||||
unset($index[$credentialId]);
|
||||
$this->writeIndex($index);
|
||||
|
||||
return $this->sessionCache->deleteItem($this->credentialKey($credentialId)) || $existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function count(): int
|
||||
{
|
||||
return \count($this->credentialIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential ids from the index only.
|
||||
*
|
||||
* Deliberately not `getKeys()`: keys are truncated to a fixed length by
|
||||
* `makeCacheKey()`, so enumerating them cannot reliably recover a full
|
||||
* credential id. The index stores the ids verbatim.
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function credentialIds(): array
|
||||
{
|
||||
return array_keys($this->readIndex());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{identity: string, label: string, createdAt: string}>
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function readIndex(): array
|
||||
{
|
||||
$item = $this->sessionCache->getItem(self::INDEX_KEY);
|
||||
$index = $item->isHit() ? $item->get() : null;
|
||||
|
||||
return \is_array($index) ? $index : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{identity: string, label: string, createdAt: string}> $index
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function writeIndex(array $index): void
|
||||
{
|
||||
$item = $this->sessionCache->getItem(self::INDEX_KEY);
|
||||
$item->set($index);
|
||||
$item->expiresAt($this->forever());
|
||||
$this->sessionCache->save($item);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{identity: string, label: string, createdAt: string} $entry
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function writeIndexEntry(string $credentialId, array $entry): void
|
||||
{
|
||||
$index = $this->readIndex();
|
||||
$index[$credentialId] = $entry;
|
||||
$this->writeIndex($index);
|
||||
}
|
||||
|
||||
/** Credentials and the index are kept indefinitely; only removal clears them. */
|
||||
private function forever(): DateTimeImmutable
|
||||
{
|
||||
return DateTimeImmutable::createFromFormat('Y-m-d', AppConstants::FAR_FUTURE_DATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key for one credential id.
|
||||
*
|
||||
* The id is hashed rather than passed through `makeCacheKey()`, because that
|
||||
* sanitises the base64url alphabet into a single `_` and is therefore not
|
||||
* injective: "abc-def" and "abc_def" both become "abc_def", so two distinct
|
||||
* credentials could share one cache slot and one of them would silently
|
||||
* overwrite the other. A hash is injective for practical purposes and keeps
|
||||
* the key within the allowed character set.
|
||||
*/
|
||||
private function credentialKey(string $credentialId): string
|
||||
{
|
||||
return self::PREFIX.hash('sha256', $credentialId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use Webauthn\CredentialRecord;
|
||||
|
||||
/**
|
||||
* Persistence for registered passkeys, backed by the `sessionCache` pool so that
|
||||
* credentials survive a container restart the same way sessions do.
|
||||
*
|
||||
* The identity is stored alongside the credential and is authoritative on
|
||||
* assertion: the `userHandle` a client returns is attacker-controlled and is
|
||||
* compared for consistency but never used to decide who is logging in.
|
||||
*/
|
||||
interface PasskeyCredentialStoreInterface
|
||||
{
|
||||
/**
|
||||
* Every registered credential, for the login ceremony's `allowCredentials`.
|
||||
*
|
||||
* Credentials whose stored payload cannot be read are skipped rather than
|
||||
* failing the ceremony, so one bad entry cannot lock everyone out.
|
||||
*
|
||||
* @return PasskeyCredential[]
|
||||
*/
|
||||
public function all(): array;
|
||||
|
||||
/**
|
||||
* Look up a single credential by its raw credential id.
|
||||
*/
|
||||
public function find(string $credentialId): ?PasskeyCredential;
|
||||
|
||||
/**
|
||||
* Every credential belonging to one identity.
|
||||
*
|
||||
* @return PasskeyCredential[]
|
||||
*/
|
||||
public function findByIdentity(string $identity): array;
|
||||
|
||||
/**
|
||||
* Persist a newly registered credential.
|
||||
*/
|
||||
public function save(PasskeyCredential $credential): void;
|
||||
|
||||
/**
|
||||
* Record that a credential was just used, refreshing its counter.
|
||||
*/
|
||||
public function updateUsage(CredentialRecord $record): void;
|
||||
|
||||
/**
|
||||
* Forget a credential.
|
||||
*
|
||||
* @return bool true when a credential was removed
|
||||
*/
|
||||
public function remove(string $credentialId): bool;
|
||||
|
||||
public function count(): int;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Both WebAuthn ceremonies, expressed without any library types in the
|
||||
* signature so callers never depend on webauthn-lib directly.
|
||||
*/
|
||||
interface PasskeyInterface
|
||||
{
|
||||
/**
|
||||
* Step 1 of the login ceremony: issue a challenge and the credential list.
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function beginLogin(): array;
|
||||
|
||||
/**
|
||||
* Step 3 of the login ceremony: verify an assertion.
|
||||
*
|
||||
* Null means "not authenticated", and the caller must not distinguish
|
||||
* between the possible causes.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function finishLogin(array $body): ?PasskeyCredential;
|
||||
|
||||
/**
|
||||
* Step 1 of the registration ceremony. Only reachable once the caller has
|
||||
* already authenticated with a TOTP code.
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function beginRegistration(string $identity): array;
|
||||
|
||||
/**
|
||||
* Step 3 of the registration ceremony: verify an attestation and store the
|
||||
* credential.
|
||||
*
|
||||
* Null means "not registered".
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function finishRegistration(array $body): ?PasskeyCredential;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\Service\PasskeyCeremonyStoreInterface as Ceremonies;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||
use Throwable;
|
||||
use Webauthn\AuthenticatorAssertionResponse;
|
||||
use Webauthn\AuthenticatorAttestationResponse;
|
||||
use Webauthn\AuthenticatorSelectionCriteria;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\PublicKeyCredential;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialDescriptor;
|
||||
use Webauthn\PublicKeyCredentialParameters;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialRpEntity;
|
||||
use Webauthn\PublicKeyCredentialUserEntity;
|
||||
|
||||
/**
|
||||
* Owns both WebAuthn ceremonies.
|
||||
*
|
||||
* **Library containment.** Every `Webauthn\*` type used by the application is
|
||||
* referenced in this file and {@see PasskeyCeremonyFactory}, so a v6 rename
|
||||
* touches two files rather than the whole codebase.
|
||||
*
|
||||
* **What is never trusted.** The challenge (server-authoritative, single-use —
|
||||
* see {@see PasskeyCeremonyStore}), the `userHandle` (read from the stored
|
||||
* credential, never from the client), and the credential id (it selects which
|
||||
* stored record to verify against). The library performs the cryptographic
|
||||
* verification of origin, RP ID hash, challenge, user presence/verification and
|
||||
* signature; this class supplies the inputs and interprets the outcome.
|
||||
*/
|
||||
final readonly class PasskeyManager implements PasskeyInterface
|
||||
{
|
||||
/** ES256 is mandatory for WebAuthn; other algorithms buy nothing here. */
|
||||
private const int COSE_ALGORITHM_ES256 = -7;
|
||||
|
||||
public function __construct(
|
||||
private PasskeyPolicyInterface $policy,
|
||||
private Ceremonies $ceremonies,
|
||||
private PasskeyCredentialStoreInterface $credentials,
|
||||
private PasskeyCeremonyFactory $factory,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the login ceremony.
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function beginLogin(): array
|
||||
{
|
||||
$ceremony = $this->ceremonies->startLogin();
|
||||
|
||||
$options = new PublicKeyCredentialRequestOptions(
|
||||
$ceremony['challenge'],
|
||||
$this->policy->rpId(),
|
||||
$this->credentialDescriptors(),
|
||||
$this->policy->userVerification(),
|
||||
$this->policy->timeout(),
|
||||
);
|
||||
|
||||
return [
|
||||
'publicKey' => $this->factory->optionsAsArray($options),
|
||||
'ceremonyId' => $ceremony['ceremonyId'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the login ceremony: verify the assertion and report the identity.
|
||||
*
|
||||
* Returns null on any failure — the caller must not be able to distinguish
|
||||
* "unknown credential" from "bad signature" from "wrong origin", or the
|
||||
* endpoint becomes an oracle for credential enumeration.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function finishLogin(array $body): ?PasskeyCredential
|
||||
{
|
||||
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
|
||||
$credentialJson = $body['credential'] ?? null;
|
||||
|
||||
if (null === $ceremonyId || !\is_array($credentialJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_LOGIN);
|
||||
if (null === $ceremony) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$publicKeyCredential = $this->deserializeCredential($credentialJson);
|
||||
if (!$publicKeyCredential instanceof PublicKeyCredential
|
||||
|| !$publicKeyCredential->response instanceof AuthenticatorAssertionResponse
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* the credential id selects the record: an attacker cannot nominate a
|
||||
* different credential than the one they hold the key for */
|
||||
$stored = $this->credentials->find($publicKeyCredential->rawId);
|
||||
if (null === $stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = $this->factory
|
||||
->requestCeremonyValidator($this->policy->allowedOrigins())
|
||||
->check(
|
||||
$stored->record,
|
||||
$publicKeyCredential->response,
|
||||
$this->requestOptions($ceremony['challenge']),
|
||||
$this->policy->authSubdomain(),
|
||||
$stored->record->userHandle,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
/* deliberately not surfaced: see the note above */
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->credentials->updateUsage($updated);
|
||||
|
||||
return $stored->withUsage($updated, new DateTimeImmutable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the registration ceremony. Only reachable once the caller has
|
||||
* already proven possession of the TOTP secret (see the flow in the plan).
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function beginRegistration(string $identity): array
|
||||
{
|
||||
$ceremony = $this->ceremonies->startRegistration($identity);
|
||||
|
||||
return [
|
||||
'publicKey' => $this->factory->optionsAsArray($this->creationOptions(
|
||||
$ceremony['challenge'],
|
||||
$identity,
|
||||
$ceremony['userHandle'],
|
||||
)),
|
||||
'ceremonyId' => $ceremony['ceremonyId'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the registration ceremony: verify the attestation and persist
|
||||
* the credential under the identity that was authenticated in step 1.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function finishRegistration(array $body): ?PasskeyCredential
|
||||
{
|
||||
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
|
||||
$credentialJson = $body['credential'] ?? null;
|
||||
|
||||
if (null === $ceremonyId || !\is_array($credentialJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_REGISTER);
|
||||
if (null === $ceremony || !isset($ceremony['identity'], $ceremony['userHandle'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$publicKeyCredential = $this->deserializeCredential($credentialJson);
|
||||
if (!$publicKeyCredential instanceof PublicKeyCredential
|
||||
|| !$publicKeyCredential->response instanceof AuthenticatorAttestationResponse
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$record = $this->factory
|
||||
->creationCeremonyValidator($this->policy->allowedOrigins())
|
||||
->check(
|
||||
$publicKeyCredential->response,
|
||||
$this->creationOptions(
|
||||
$ceremony['challenge'],
|
||||
$ceremony['identity'],
|
||||
$ceremony['userHandle'],
|
||||
),
|
||||
$this->policy->authSubdomain(),
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$credential = new PasskeyCredential(
|
||||
$record,
|
||||
$ceremony['identity'],
|
||||
$this->labelFor($record),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$this->credentials->save($credential);
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
private function requestOptions(string $challenge): PublicKeyCredentialRequestOptions
|
||||
{
|
||||
return new PublicKeyCredentialRequestOptions(
|
||||
$challenge,
|
||||
$this->policy->rpId(),
|
||||
$this->credentialDescriptors(),
|
||||
/* userVerification must be present for the library to enforce the
|
||||
* user-verified flag; it is not re-read from the client request */
|
||||
$this->policy->userVerification(),
|
||||
$this->policy->timeout(),
|
||||
);
|
||||
}
|
||||
|
||||
private function creationOptions(string $challenge, string $identity, string $userHandle): PublicKeyCredentialCreationOptions
|
||||
{
|
||||
return new PublicKeyCredentialCreationOptions(
|
||||
new PublicKeyCredentialRpEntity($this->policy->rpName(), $this->policy->rpId()),
|
||||
new PublicKeyCredentialUserEntity($identity, $userHandle, $identity),
|
||||
$challenge,
|
||||
[PublicKeyCredentialParameters::create('public-key', self::COSE_ALGORITHM_ES256)],
|
||||
new AuthenticatorSelectionCriteria(
|
||||
/* a passkey for this device, which is what the "register this
|
||||
* device" checkbox promises */
|
||||
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
|
||||
$this->policy->userVerification(),
|
||||
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
|
||||
),
|
||||
/* D5: 'none' — see the plan §2.3 and SECURITY.md */
|
||||
'none',
|
||||
/* also discourage registering the same device twice */
|
||||
$this->credentialDescriptors(),
|
||||
$this->policy->timeout(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptors for every stored credential, used both as `allowCredentials`
|
||||
* on login and as `excludeCredentials` on registration.
|
||||
*
|
||||
* @return PublicKeyCredentialDescriptor[]
|
||||
*/
|
||||
private function credentialDescriptors(): array
|
||||
{
|
||||
$descriptors = [];
|
||||
foreach ($this->credentials->all() as $credential) {
|
||||
$descriptors[] = PublicKeyCredentialDescriptor::create(
|
||||
'public-key',
|
||||
$credential->record->publicKeyCredentialId,
|
||||
$credential->record->transports,
|
||||
);
|
||||
}
|
||||
|
||||
return $descriptors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
private function deserializeCredential(array $data): ?PublicKeyCredential
|
||||
{
|
||||
try {
|
||||
return $this->factory->serializer()->denormalize($data, PublicKeyCredential::class, 'json');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, stable description so a user can tell their keys apart.
|
||||
*
|
||||
* Derived from the credential id because the AAGUID is not a reliable
|
||||
* identity without the FIDO metadata service, which D5 deliberately does
|
||||
* not use.
|
||||
*/
|
||||
private function labelFor(CredentialRecord $record): string
|
||||
{
|
||||
return 'Passkey '.substr(Base64UrlSafe::encodeUnpadded($record->publicKeyCredentialId), 0, 8);
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
return \is_string($value) && '' !== $value ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* @see PasskeyPolicyInterface for the decisions this class enforces
|
||||
*
|
||||
* **On scheme handling (D4).** The container serves plain HTTP and sits behind a
|
||||
* TLS-terminating proxy, so the application never learns the public scheme from
|
||||
* its own configuration — only from `X-Forwarded-Proto`, which Symfony resolves
|
||||
* through `trusted_proxies` / `trusted_headers` in `framework.yaml`. The scheme
|
||||
* is therefore enforced two independent ways rather than asserted at boot:
|
||||
*
|
||||
* 1. The allowed origin is hardcoded to `https://` here and is never taken from
|
||||
* the request, so the library's origin check rejects an `http://` ceremony
|
||||
* no matter how the request arrived.
|
||||
* 2. {@see isAvailableFor()} additionally requires the request to be secure, so
|
||||
* a visitor on a non-secure connection is never shown a passkey button that
|
||||
* the browser would refuse to act on.
|
||||
*/
|
||||
final readonly class PasskeyPolicy implements PasskeyPolicyInterface
|
||||
{
|
||||
private const string SCHEME = 'https';
|
||||
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->config->passkeyEnabled() && null !== $this->domainManager->authBase();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function isAvailableFor(Request $request): bool
|
||||
{
|
||||
return $this->isEnabled()
|
||||
&& $request->isSecure()
|
||||
&& $this->domainManager->getAuthSubdomain() === $request->getHost();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function rpId(): string
|
||||
{
|
||||
return $this->domainManager->authBase() ?? throw $this->notConfigured();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function allowedOrigins(): array
|
||||
{
|
||||
return [self::SCHEME.'://'.$this->authSubdomain()];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function authSubdomain(): string
|
||||
{
|
||||
$subdomain = $this->domainManager->getAuthSubdomain();
|
||||
|
||||
return $subdomain ?? throw $this->notConfigured();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function rpName(): string
|
||||
{
|
||||
return $this->config->passkeyRpName() ?: $this->config->title();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function userVerification(): string
|
||||
{
|
||||
return $this->config->passkeyUserVerification();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function timeout(): int
|
||||
{
|
||||
return $this->config->passkeyTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PasskeyConfigurationException
|
||||
*/
|
||||
#[Override]
|
||||
public function assertConfigurationIsUsable(): void
|
||||
{
|
||||
if (!$this->config->passkeyEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* D1: without a base domain there is no RP ID and no shared credential */
|
||||
if (null === $this->domainManager->authBase()) {
|
||||
throw new PasskeyConfigurationException('PASSKEY_ENABLED is on, but central authentication is not configured. Passkeys require SUBDOMAIN_REDIRECT=1 together with a valid AUTH_SUBDOMAIN whose base domain can be determined (a domain such as "auth.example.com" — not "localhost" and not an IP address). Either configure central authentication or set PASSKEY_ENABLED=0.');
|
||||
}
|
||||
|
||||
/* D4: the subdomain must be a real domain able to present a TLS certificate.
|
||||
* authBase() returning null already excludes localhost and bare IPs, so
|
||||
* this guards against an auth subdomain that is a single label. */
|
||||
if (!str_contains($this->authSubdomain(), '.')) {
|
||||
throw new PasskeyConfigurationException('AUTH_SUBDOMAIN must be a fully qualified domain name (for example "auth.example.com") because passkeys require HTTPS and a certificate cannot be issued for a single-label host.');
|
||||
}
|
||||
}
|
||||
|
||||
private function notConfigured(): PasskeyConfigurationException
|
||||
{
|
||||
return new PasskeyConfigurationException(
|
||||
'Passkeys are enabled but central authentication is not configured, '
|
||||
.'so no relying party identity is available.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Single source of truth for whether passkeys may be offered, and under which
|
||||
* relying-party identity.
|
||||
*
|
||||
* Two project decisions are enforced here and nowhere else:
|
||||
*
|
||||
* - **D1 — central auth is a prerequisite.** A passkey is only meaningful when
|
||||
* the whole base domain shares one relying party, so passkeys are unavailable
|
||||
* unless `SUBDOMAIN_REDIRECT` is on and `AUTH_SUBDOMAIN` resolves to a base
|
||||
* domain. The RP ID is therefore always that base domain.
|
||||
*
|
||||
* - **D4 — HTTPS is required, with no exemption.** The origin handed to the
|
||||
* browser is built here as `https://{authSubdomain}` and is *never* derived
|
||||
* from the incoming request, so an `http://` origin can never be accepted.
|
||||
* The deprecated `setSecuredRelyingPartyId()` escape hatch is not used, and
|
||||
* there is deliberately no configuration override that could reintroduce one.
|
||||
*/
|
||||
interface PasskeyPolicyInterface
|
||||
{
|
||||
/**
|
||||
* True when passkeys are switched on by configuration AND that configuration
|
||||
* satisfies D1. Availability to a particular visitor additionally requires
|
||||
* {@see isAvailableFor()}.
|
||||
*/
|
||||
public function isEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Whether the passkey UI should be offered for this request.
|
||||
*
|
||||
* Requires the feature to be enabled, the request to be aimed at the auth
|
||||
* subdomain (the only place a ceremony may run), and the visitor to actually
|
||||
* be on HTTPS — see the note on scheme handling in {@see PasskeyPolicy}.
|
||||
*/
|
||||
public function isAvailableFor(Request $request): bool;
|
||||
|
||||
/**
|
||||
* The relying party ID: always the base domain of the auth subdomain.
|
||||
*
|
||||
* @throws PasskeyConfigurationException when passkeys are enabled without D1
|
||||
*/
|
||||
public function rpId(): string;
|
||||
|
||||
/**
|
||||
* Relying party origins allowed to complete a ceremony.
|
||||
*
|
||||
* Always exactly one entry, always `https://`, always derived from
|
||||
* configuration rather than from the request (D4).
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @throws PasskeyConfigurationException when passkeys are enabled without D1
|
||||
*/
|
||||
public function allowedOrigins(): array;
|
||||
|
||||
/** The auth subdomain that ceremonies must be served from. */
|
||||
public function authSubdomain(): string;
|
||||
|
||||
/** Human-readable name shown in the authenticator prompt. */
|
||||
public function rpName(): string;
|
||||
|
||||
/** `required`, `preferred` or `discouraged`. */
|
||||
public function userVerification(): string;
|
||||
|
||||
/** Ceremony timeout in milliseconds, as passed to the browser. */
|
||||
public function timeout(): int;
|
||||
|
||||
/**
|
||||
* Fails hard when passkeys are enabled in a configuration that cannot work.
|
||||
*
|
||||
* Called during cache warm-up so a misconfigured deployment never reaches a
|
||||
* browser: the container refuses to start instead.
|
||||
*
|
||||
* @throws PasskeyConfigurationException
|
||||
*/
|
||||
public function assertConfigurationIsUsable(): void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\Scope;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
/**
|
||||
* Grants access once an identity has been authenticated, by whatever method.
|
||||
*
|
||||
* Extracted from `LoginManager` so the passkey ceremony and the TOTP form
|
||||
* produce **byte-identical** outcomes. Two implementations would inevitably
|
||||
* drift — most likely in cookie attributes, where a difference is invisible
|
||||
* until it breaks in a browser.
|
||||
*
|
||||
* This class deliberately knows nothing about *how* authentication happened; it
|
||||
* only records the result.
|
||||
*
|
||||
* @see SessionIssuerInterface
|
||||
*/
|
||||
final readonly class SessionIssuer implements SessionIssuerInterface
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response
|
||||
{
|
||||
/* the same normalisation LoginManager has always applied, so cache keys
|
||||
* and Remote-User values stay identical between the two login paths */
|
||||
$cleanId = $this->makeCacheKey($identity);
|
||||
|
||||
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||
|
||||
/* when the caller only wanted this one page, there is nothing to store */
|
||||
if (Scope::None === $scope) {
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (Scope::Cookie === $scope) {
|
||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||
} elseif (Scope::Ip === $scope) {
|
||||
$this->setIp($cleanId, (string) $request->getClientIp());
|
||||
}
|
||||
|
||||
if ($json) {
|
||||
$contentType = 'application/json';
|
||||
$content = (string) json_encode([
|
||||
'message' => 'Login successful',
|
||||
'nonce' => null,
|
||||
]);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = "hi $cleanId, please reload";
|
||||
}
|
||||
|
||||
$location = $request->query->has('return')
|
||||
&& $this->domainManager->validReturn((string) $request->query->get('return')) ?
|
||||
"{$request->query->get('return')}" :
|
||||
"{$request->getPathInfo()}{$request->getQueryString()}";
|
||||
|
||||
/* force redirect to use GET method (important when using central auth) */
|
||||
$response->setContent($content)
|
||||
->setStatusCode(Response::HTTP_SEE_OTHER)
|
||||
->headers->set('Location', $location);
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function setCookie(string $id, string $host): Cookie
|
||||
{
|
||||
/* successful auth with token, store session and set the cookie */
|
||||
$ulid = new Ulid();
|
||||
$sessionCookie = $this->sessionCache->getItem(
|
||||
$this->makeCacheKey("cookie_$ulid"),
|
||||
);
|
||||
if ($sessionCookie->isHit()) {
|
||||
/* it is supposed to be impossible to have collisions */
|
||||
$this->logger->error('aborting: ULID collision');
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
}
|
||||
$sessionCookie->set($id);
|
||||
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
||||
$this->sessionCache->save($sessionCookie);
|
||||
|
||||
return Cookie::create(
|
||||
name: $this->sessionCookieName($this->domainManager),
|
||||
value: $ulid->toString(),
|
||||
expire: time() + $this->config->cookieTtl(),
|
||||
path: '/',
|
||||
domain: $this->sessionCookieDomain($this->domainManager, $host),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: Cookie::SAMESITE_STRICT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function setIp(string $id, string $ip): void
|
||||
{
|
||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||
|
||||
$sessionIp = $this->sessionCache->getItem($ipKey);
|
||||
$sessionIp->set($id);
|
||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
||||
$this->sessionCache->save($sessionIp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enum\Scope;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Grants access after a successful authentication, regardless of method.
|
||||
*
|
||||
* Exists so the TOTP form and the passkey ceremony cannot drift apart: they must
|
||||
* produce identical cookies, headers and redirects, and the only reliable way to
|
||||
* guarantee that is for both to call the same code.
|
||||
*/
|
||||
interface SessionIssuerInterface
|
||||
{
|
||||
/**
|
||||
* Record the authenticated identity according to the requested scope and
|
||||
* build the response the caller should return.
|
||||
*
|
||||
* @param string $identity the session id, as typed by the user
|
||||
* @param Scope $scope whether to set a cookie, an IP session, or neither
|
||||
* @param bool $json JSON for an AJAX caller, HTML for a form post
|
||||
*/
|
||||
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ trait GetTotpTrait
|
||||
{
|
||||
$otp = Factory::loadFromProvisioningUri(
|
||||
$this->config->totpUri(),
|
||||
$this->config->clock()
|
||||
$this->config->clock(),
|
||||
);
|
||||
if ($otp instanceof TOTPInterface) {
|
||||
return $otp;
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Trait;
|
||||
use Exception;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
@@ -23,8 +24,9 @@ trait MakeNonceTrait
|
||||
protected readonly CacheItemPoolInterface $nonceCache;
|
||||
|
||||
#[Required]
|
||||
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
|
||||
{
|
||||
public function setNonceCache(
|
||||
#[Target('nonceCache')] CacheItemPoolInterface $nonceCache,
|
||||
): void {
|
||||
$this->nonceCache = $nonceCache;
|
||||
}
|
||||
|
||||
@@ -33,18 +35,16 @@ trait MakeNonceTrait
|
||||
{
|
||||
/* convert raw binary into base64url */
|
||||
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
||||
static::NONCE_LENGTH
|
||||
static::NONCE_LENGTH,
|
||||
)), '+/', '-_'), '=');
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
|
||||
|
||||
if ($nonceItem->isHit()) {
|
||||
if ($retries < 1) {
|
||||
$this->logger->error("aborting: multiple nonce collisions");
|
||||
throw new HttpException(
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR,
|
||||
'Internal Server Error'
|
||||
);
|
||||
$this->logger->error('aborting: multiple nonce collisions');
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
}
|
||||
|
||||
/* managed to have a collision, try again */
|
||||
return $this->makeNonce($retries - 1);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ trait MakeNonceTrait
|
||||
$nonceItem->expiresAfter(static::NONCE_TTL);
|
||||
$this->logger->debug("added nonce: $nonce");
|
||||
$this->nonceCache->save($nonceItem);
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ trait StringTrait
|
||||
$headers = ['Content-Type' => 'text/plain'];
|
||||
|
||||
$headerValue = $this->resolveRemoteUser($id, $config);
|
||||
if ($headerValue !== null) {
|
||||
if (null !== $headerValue) {
|
||||
$headers['Remote-User'] = $headerValue;
|
||||
}
|
||||
|
||||
@@ -45,9 +45,9 @@ trait StringTrait
|
||||
{
|
||||
return match ($config->remoteUserMode()) {
|
||||
RemoteUserMode::Session => $id,
|
||||
RemoteUserMode::Static => $config->remoteUserStatic(),
|
||||
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
|
||||
RemoteUserMode::None => null,
|
||||
RemoteUserMode::Static => $config->remoteUserStatic(),
|
||||
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
|
||||
RemoteUserMode::None => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -15,7 +15,7 @@ use Psr\Clock\ClockInterface;
|
||||
final readonly class Utilities
|
||||
{
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private ClockInterface $clock,
|
||||
private CacheItemPoolInterface $appPool,
|
||||
) {
|
||||
}
|
||||
@@ -31,6 +31,7 @@ final readonly class Utilities
|
||||
}
|
||||
|
||||
$this->showTotp($totp);
|
||||
|
||||
return $totp;
|
||||
}
|
||||
|
||||
@@ -47,9 +48,10 @@ final readonly class Utilities
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
AppConstants::FAR_FUTURE_DATE
|
||||
AppConstants::FAR_FUTURE_DATE,
|
||||
));
|
||||
$this->appPool->save($totpItem);
|
||||
|
||||
return $totp;
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ final readonly class Utilities
|
||||
loading TOTP, because the env is not set, please copy above into TOTP_URI
|
||||
|
||||
RAW,
|
||||
FILE_APPEND
|
||||
\FILE_APPEND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
|
||||
}
|
||||
},
|
||||
"friendsofphp/php-cs-fixer": {
|
||||
"version": "3.95",
|
||||
"recipe": {
|
||||
@@ -11,6 +20,9 @@
|
||||
".php-cs-fixer.dist.php"
|
||||
]
|
||||
},
|
||||
"phpstan/phpstan": {
|
||||
"version": "2.2.15"
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.2",
|
||||
"recipe": {
|
||||
@@ -71,6 +83,18 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
|
||||
@@ -19,6 +19,8 @@ form.addEventListener('submit', (event) => {
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: { 'X-Preauth': data },
|
||||
// never serve this request from, or store it in, the HTTP cache
|
||||
cache: 'no-store',
|
||||
}).then((response) => {
|
||||
{% if env.debug > 2 -%}
|
||||
console.log(response);
|
||||
@@ -28,7 +30,8 @@ form.addEventListener('submit', (event) => {
|
||||
{% if env.debug > 2 -%}
|
||||
console.log('got redirect response');
|
||||
{% 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) {
|
||||
{# got json, update the page #}
|
||||
{% if env.debug > 2 -%}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<style id="preauth-style">
|
||||
* { margin: 0; padding: 0.25em; }
|
||||
html { background-color: {{ env.bg_color|e('css') }}; color: {{ env.fg_color|e('css') }}; display: table;
|
||||
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
|
||||
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
|
||||
body { display: table-cell; vertical-align: middle; }
|
||||
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
|
||||
p { color: {{ env.error_color|e('css') }}; text-align: center; }
|
||||
p { color: {{ env.error_color }}; text-align: center; }
|
||||
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
|
||||
form div { width: 45%; min-width: 300px; }
|
||||
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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') -}}
|
||||
</head>
|
||||
<body id="preauth-body">
|
||||
|
||||
@@ -42,7 +42,8 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
/** 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);
|
||||
$json = json_encode($data, \JSON_THROW_ON_ERROR);
|
||||
|
||||
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
@@ -53,16 +54,16 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
bool $json = true,
|
||||
): string {
|
||||
return $this->encodePayload([
|
||||
'id' => $id,
|
||||
'id' => $id,
|
||||
'token' => $token ?? $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => $json,
|
||||
'json' => $json,
|
||||
]);
|
||||
}
|
||||
|
||||
/* ── unauthenticated access ──────────────────────────────────────── */
|
||||
|
||||
public function testUnauthenticatedRequestShowsLoginPage(): void
|
||||
public function test_unauthenticated_request_shows_login_page(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/');
|
||||
@@ -75,7 +76,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertSelectorExists('input[name="totp"]');
|
||||
}
|
||||
|
||||
public function testLoginPageContainsGeneratedNonce(): void
|
||||
public function test_login_page_contains_generated_nonce(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$crawler = $client->request('GET', '/');
|
||||
@@ -86,7 +87,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
|
||||
}
|
||||
|
||||
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void
|
||||
public function test_login_form_does_not_use_post_method_without_auth_subdomain(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$crawler = $client->request('GET', '/');
|
||||
@@ -99,7 +100,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
/* ── successful TOTP login ────────────────────────────────────────── */
|
||||
|
||||
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
|
||||
public function test_successful_totp_login_via_header_sets_cookie_and_redirects(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -111,10 +112,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
// now submit a valid TOTP via the X-Preauth header
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -132,7 +133,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void
|
||||
public function test_successful_login_returns_json_when_json_requested(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -141,10 +142,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'bob',
|
||||
'id' => 'bob',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -155,7 +156,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertSame('Login successful', $body['message']);
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
|
||||
public function test_successful_login_returns_html_when_json_false(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -164,10 +165,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'carol',
|
||||
'id' => 'carol',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => false,
|
||||
'json' => false,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -176,7 +177,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function testAuthenticatedCookieAccessAfterLogin(): void
|
||||
public function test_authenticated_cookie_access_after_login(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -186,10 +187,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'dave',
|
||||
'id' => 'dave',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -214,7 +215,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertSame('dave', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void
|
||||
public function test_scope_none_returns_plain_text_without_redirect(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -223,7 +224,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'eve',
|
||||
'id' => 'eve',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'scope' => 'none',
|
||||
@@ -240,7 +241,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
/* ── failed login ─────────────────────────────────────────────────── */
|
||||
|
||||
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
|
||||
public function test_failed_login_returns_unauthorized_json_with_error(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -249,10 +250,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'id' => 'alice',
|
||||
'token' => '000000', // wrong code
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -266,7 +267,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertNotEmpty($body['nonce']);
|
||||
}
|
||||
|
||||
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
|
||||
public function test_failed_login_returns_html_when_json_false(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -275,10 +276,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'id' => 'alice',
|
||||
'token' => 'wrong-code',
|
||||
'nonce' => $nonce,
|
||||
'json' => false,
|
||||
'json' => false,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -288,7 +289,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertSelectorExists('form#preauth-form');
|
||||
}
|
||||
|
||||
public function testFailedLoginWithSpentNonceIsRejected(): void
|
||||
public function test_failed_login_with_spent_nonce_is_rejected(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -298,10 +299,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
// first: successful login consumes the nonce
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||
@@ -314,26 +315,26 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
// reuse the same nonce — should fail even with a valid token
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testFailedLoginWithInvalidNonceIsRejected(): void
|
||||
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',
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => 'never-issued-nonce',
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -342,7 +343,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
/* ── invalid payload ──────────────────────────────────────────────── */
|
||||
|
||||
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
|
||||
public function test_invalid_header_payload_returns_unauthorized(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -354,7 +355,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
|
||||
public function test_payload_with_missing_fields_returns_unauthorized(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -370,7 +371,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
/* ── invalid cookie ───────────────────────────────────────────────── */
|
||||
|
||||
public function testInvalidCookieIsClearedAndLoginPageShown(): void
|
||||
public function test_invalid_cookie_is_cleared_and_login_page_shown(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -388,7 +389,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
true,
|
||||
false,
|
||||
'Strict',
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
$client->request('GET', 'https://localhost/');
|
||||
@@ -399,7 +400,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
// the stale cookie should be cleared
|
||||
$cleared = false;
|
||||
foreach ($response->headers->getCookies() as $cookie) {
|
||||
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
|
||||
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
|
||||
$cleared = true;
|
||||
}
|
||||
}
|
||||
@@ -408,7 +409,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
/* ── backup code authentication ───────────────────────────────────── */
|
||||
|
||||
public function testBackupCodeAuthenticationWorks(): void
|
||||
public function test_backup_code_authentication_works(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$container = $client->getContainer();
|
||||
@@ -423,17 +424,17 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'frank',
|
||||
'id' => 'frank',
|
||||
'token' => $codes[0],
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testConsumedBackupCodeCannotBeReused(): void
|
||||
public function test_consumed_backup_code_cannot_be_reused(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$container = $client->getContainer();
|
||||
@@ -469,7 +470,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
|
||||
/* ── return URL handling ──────────────────────────────────────────── */
|
||||
|
||||
public function testSuccessfulLoginWithValidReturnUrl(): void
|
||||
public function test_successful_login_with_valid_return_url(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
@@ -488,7 +489,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
||||
self::assertSame('https://example.com/app', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
|
||||
public function test_successful_login_with_invalid_return_falls_back_to_path(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Service\DomainManager;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\RateLimiter\LimiterInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimit;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
use Symfony\Component\RateLimiter\LimiterInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
@@ -25,26 +22,27 @@ trait ListenerTestHelper
|
||||
/** Build a Twig Environment pointed at the project's real templates. */
|
||||
private function makeTwig(): Environment
|
||||
{
|
||||
$loader = new FilesystemLoader(dirname(__DIR__, 2) . '/templates');
|
||||
$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',
|
||||
$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,
|
||||
'debug' => 0,
|
||||
]);
|
||||
|
||||
return $twig;
|
||||
}
|
||||
|
||||
@@ -55,10 +53,12 @@ trait ListenerTestHelper
|
||||
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
|
||||
{
|
||||
$limiter = $this->makeLimiter($remainingTokens);
|
||||
return new class ($limiter) implements RateLimiterFactoryInterface {
|
||||
|
||||
return new class($limiter) implements RateLimiterFactoryInterface {
|
||||
public function __construct(private LimiterInterface $limiter)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(?string $key = null): LimiterInterface
|
||||
{
|
||||
return $this->limiter;
|
||||
@@ -70,22 +70,26 @@ trait ListenerTestHelper
|
||||
{
|
||||
$rateLimit = new RateLimit(
|
||||
$remainingTokens,
|
||||
new \DateTimeImmutable('+10 seconds'),
|
||||
new DateTimeImmutable('+10 seconds'),
|
||||
$remainingTokens > 0,
|
||||
10,
|
||||
);
|
||||
return new class ($rateLimit) implements LimiterInterface {
|
||||
|
||||
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
|
||||
{
|
||||
}
|
||||
@@ -98,35 +102,42 @@ trait ListenerTestHelper
|
||||
*/
|
||||
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
|
||||
{
|
||||
$limiter = new class ($threshold) implements LimiterInterface {
|
||||
$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'),
|
||||
new DateTimeImmutable('+10 seconds'),
|
||||
$remaining > 0,
|
||||
$this->threshold,
|
||||
);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->consumed = 0;
|
||||
}
|
||||
};
|
||||
return new class ($limiter) implements RateLimiterFactoryInterface {
|
||||
|
||||
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,273 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use CBOR\ByteStringObject;
|
||||
use CBOR\Encoder;
|
||||
use JsonException;
|
||||
use OpenSSLAsymmetricKey;
|
||||
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Builds real, cryptographically valid WebAuthn ceremonies for tests.
|
||||
*
|
||||
* Nothing here is mocked: the helper generates a P-256 keypair, builds a proper
|
||||
* COSE public key, signs a correct `authenticatorData` with OpenSSL, and
|
||||
* assembles the CBOR `attestationObject` a real authenticator would send. A
|
||||
* passing test therefore proves the ceremony works, rather than proving that our
|
||||
* code agrees with our own stubs.
|
||||
*
|
||||
* Two rules learned the hard way, both encoded below:
|
||||
* - **The counter is controlled explicitly.** A stale counter raises
|
||||
* `CounterException`, which can mask the real reason a verification failed and
|
||||
* turn a negative test into a false pass.
|
||||
* - **Options are serialised by the library**, never `json_encode()`d — the
|
||||
* challenge is raw binary and `json_encode()` rejects it outright.
|
||||
*/
|
||||
final class PasskeyTestHelper
|
||||
{
|
||||
/** The `none` attestation format requires an all-zero AAGUID. */
|
||||
private const string ZERO_AAGUID = '00000000000000000000000000000000';
|
||||
|
||||
private readonly OpenSSLAsymmetricKey $key;
|
||||
|
||||
/** @var array{x: string, y: string} */
|
||||
private array $coordinates;
|
||||
|
||||
private int $counter = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_type' => \OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => 'prime256v1',
|
||||
]);
|
||||
|
||||
if (!$key instanceof OpenSSLAsymmetricKey) {
|
||||
throw new RuntimeException('Unable to generate a P-256 keypair for tests.');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($key);
|
||||
if (!\is_array($details) || !isset($details['ec']['x'], $details['ec']['y'])) {
|
||||
throw new RuntimeException('Unable to read the generated P-256 keypair.');
|
||||
}
|
||||
|
||||
$this->key = $key;
|
||||
$this->coordinates = [
|
||||
'x' => $details['ec']['x'],
|
||||
'y' => $details['ec']['y'],
|
||||
];
|
||||
$this->counter = 0;
|
||||
}
|
||||
|
||||
public function credentialId(): string
|
||||
{
|
||||
return random_bytes(16);
|
||||
}
|
||||
|
||||
public function counter(): int
|
||||
{
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* COSE-encoded public key, as carried in the attested credential data.
|
||||
*
|
||||
* Keys are the standard COSE labels: 1 = EC2, 3 = ES256, -1 = P-256,
|
||||
* -2 = x, -3 = y. Binary coordinates must be byte strings, so they bypass
|
||||
* the encoder's UTF-8 detection.
|
||||
*/
|
||||
public function cosePublicKey(): string
|
||||
{
|
||||
return (new Encoder())->encode([
|
||||
1 => 2,
|
||||
3 => -7,
|
||||
-1 => 1,
|
||||
-2 => ByteStringObject::create($this->coordinates['x']),
|
||||
-3 => ByteStringObject::create($this->coordinates['y']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticator data for a registration: rpIdHash, flags (UP|AT|UV), a
|
||||
* counter, then the attested credential data.
|
||||
*/
|
||||
public function attestationAuthenticatorData(string $rpId, string $credentialId, bool $userVerified = true): string
|
||||
{
|
||||
$flags = 0x01 | 0x40; /* UP | AT */
|
||||
if ($userVerified) {
|
||||
$flags |= 0x04; /* UV */
|
||||
}
|
||||
|
||||
return hash('sha256', $rpId, true)
|
||||
.\chr($flags)
|
||||
.pack('N', ++$this->counter)
|
||||
.hex2bin(self::ZERO_AAGUID)
|
||||
.pack('n', \strlen($credentialId))
|
||||
.$credentialId
|
||||
.$this->cosePublicKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticator data for an assertion: rpIdHash, flags (UP|UV), a counter.
|
||||
* The attested credential data lives in the stored record, not here.
|
||||
*/
|
||||
public function assertionAuthenticatorData(string $rpId, int $counter, bool $userVerified = true): string
|
||||
{
|
||||
$flags = 0x01; /* UP */
|
||||
if ($userVerified) {
|
||||
$flags |= 0x04; /* UV */
|
||||
}
|
||||
|
||||
return hash('sha256', $rpId, true).\chr($flags).pack('N', $counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* clientDataJSON with the challenge encoded exactly as a browser encodes it.
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function clientData(string $type, string $challenge, string $origin): string
|
||||
{
|
||||
return json_encode([
|
||||
'type' => $type,
|
||||
'challenge' => Base64UrlSafe::encodeUnpadded($challenge),
|
||||
'origin' => $origin,
|
||||
'crossOrigin' => false,
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CBOR attestation object a browser sends, in the `none` format.
|
||||
*/
|
||||
public function attestationObject(string $authData): string
|
||||
{
|
||||
return (new Encoder())->encode([
|
||||
'fmt' => 'none',
|
||||
'attStmt' => [],
|
||||
'authData' => ByteStringObject::create($authData),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign `authData || sha256(clientDataJSON)` with the generated key.
|
||||
*
|
||||
* WebAuthn wants the raw 64-byte (r||s) form, but OpenSSL emits DER, so the
|
||||
* result is converted. Getting this wrong produces a confusing "invalid
|
||||
* signature" that looks like a logic bug rather than an encoding one.
|
||||
*/
|
||||
public function signature(string $authData, string $clientDataJson): string
|
||||
{
|
||||
$data = $authData.hash('sha256', $clientDataJson, true);
|
||||
|
||||
$der = '';
|
||||
openssl_sign($data, $der, $this->key, \OPENSSL_ALGO_SHA256);
|
||||
|
||||
return self::derToRaw($der);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-submit registration `credential`, matching what
|
||||
* `navigator.credentials.create()` produces.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function registrationCredential(
|
||||
string $rpId,
|
||||
string $challenge,
|
||||
string $origin,
|
||||
?string $credentialId = null,
|
||||
): array {
|
||||
$credentialId ??= $this->credentialId();
|
||||
$clientDataJson = $this->clientData('webauthn.create', $challenge, $origin);
|
||||
$authData = $this->attestationAuthenticatorData($rpId, $credentialId);
|
||||
|
||||
return [
|
||||
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'type' => 'public-key',
|
||||
'response' => [
|
||||
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
|
||||
'attestationObject' => Base64UrlSafe::encodeUnpadded($this->attestationObject($authData)),
|
||||
'transports' => ['internal'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-submit assertion `credential`, matching what
|
||||
* `navigator.credentials.get()` produces.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function assertionCredential(
|
||||
string $rpId,
|
||||
string $challenge,
|
||||
string $origin,
|
||||
string $credentialId,
|
||||
int $counter,
|
||||
string $userHandle,
|
||||
): array {
|
||||
$clientDataJson = $this->clientData('webauthn.get', $challenge, $origin);
|
||||
$authData = $this->assertionAuthenticatorData($rpId, $counter);
|
||||
|
||||
return [
|
||||
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'type' => 'public-key',
|
||||
'response' => [
|
||||
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
|
||||
'authenticatorData' => Base64UrlSafe::encodeUnpadded($authData),
|
||||
'signature' => Base64UrlSafe::encodeUnpadded($this->signature($authData, $clientDataJson)),
|
||||
'userHandle' => Base64UrlSafe::encodeUnpadded($userHandle),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an OpenSSL DER signature to the fixed-length raw form WebAuthn
|
||||
* mandates: 64 bytes for P-256, big-endian r||s.
|
||||
*
|
||||
* DER is `30 <len> 02 <lenR> R 02 <lenS> S`. Both integers are
|
||||
* variable-length and may carry a leading zero byte, so each coordinate is
|
||||
* right-aligned into exactly 32 bytes. The library rejects anything that is
|
||||
* not exactly 64 bytes, and a malformed result looks like "invalid
|
||||
* signature" rather than an encoding bug — hence the explicit round-trip
|
||||
* check in the tests.
|
||||
*
|
||||
* @throws RuntimeException when the input is not a well-formed ECDSA-Sig-Value
|
||||
*/
|
||||
private static function derToRaw(string $der): string
|
||||
{
|
||||
$length = \strlen($der);
|
||||
/* short-form SEQUENCE header: tag + one length byte */
|
||||
if ($length < 8 || 0x30 !== \ord($der[0])) {
|
||||
throw new RuntimeException('Expected a DER SEQUENCE.');
|
||||
}
|
||||
|
||||
$offset = 2;
|
||||
|
||||
if (0x02 !== \ord($der[$offset])) {
|
||||
throw new RuntimeException('Expected a DER INTEGER for r.');
|
||||
}
|
||||
$lengthR = \ord($der[$offset + 1]);
|
||||
$r = substr($der, $offset + 2, $lengthR);
|
||||
$offset += 2 + $lengthR;
|
||||
|
||||
if (0x02 !== \ord($der[$offset])) {
|
||||
throw new RuntimeException('Expected a DER INTEGER for s.');
|
||||
}
|
||||
$lengthS = \ord($der[$offset + 1]);
|
||||
$s = substr($der, $offset + 2, $lengthS);
|
||||
|
||||
return str_pad(substr($r, -32), 32, "\x00", \STR_PAD_LEFT)
|
||||
.str_pad(substr($s, -32), 32, "\x00", \STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\RemoteUserMode;
|
||||
use App\Utilities;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Clock\ClockInterface as PsrClockInterface;
|
||||
@@ -31,10 +30,13 @@ trait TotpTestHelper
|
||||
private function frozenClock(): PsrClockInterface
|
||||
{
|
||||
$time = self::FROZEN_TIME;
|
||||
return new class ($time) implements PsrClockInterface {
|
||||
|
||||
return new class($time) implements PsrClockInterface {
|
||||
public function __construct(private string $time)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable($this->time);
|
||||
@@ -47,6 +49,7 @@ trait TotpTestHelper
|
||||
{
|
||||
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
|
||||
$totp->setLabel('Test-TOTP');
|
||||
|
||||
return $totp->getProvisioningUri();
|
||||
}
|
||||
|
||||
@@ -76,9 +79,15 @@ trait TotpTestHelper
|
||||
string $remoteUserMode = 'session',
|
||||
string $remoteUserStatic = 'authenticated',
|
||||
string $remoteUserMap = '',
|
||||
string $title = 'Pre-Authentication System',
|
||||
bool $passkeyEnabled = false,
|
||||
string $passkeyRpName = '',
|
||||
string $passkeyUserVerification = 'required',
|
||||
int $passkeyTimeout = 60000,
|
||||
): ConfigBag {
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
|
||||
return new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
@@ -92,6 +101,11 @@ trait TotpTestHelper
|
||||
$remoteUserMode,
|
||||
$remoteUserStatic,
|
||||
$remoteUserMap,
|
||||
$title,
|
||||
$passkeyEnabled,
|
||||
$passkeyRpName,
|
||||
$passkeyUserVerification,
|
||||
$passkeyTimeout,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +121,7 @@ trait TotpTestHelper
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(false);
|
||||
$cache->method('getItem')->willReturn($item);
|
||||
|
||||
return new Utilities($clock, $cache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ class TestKernel extends AppKernel
|
||||
{
|
||||
parent::build($container);
|
||||
|
||||
$container->addCompilerPass(new class () implements CompilerPassInterface {
|
||||
$container->addCompilerPass(new class implements CompilerPassInterface {
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) {
|
||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache', 'passkeyRateLimitCache'] as $poolId) {
|
||||
if ($container->hasDefinition($poolId)) {
|
||||
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\CacheWarmer;
|
||||
|
||||
use App\CacheWarmer\PasskeyConfigurationWarmer;
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The warmer is the mechanism that turns a bad passkey configuration into a
|
||||
* failed deployment rather than a broken feature (D1/D4, plan §4.2).
|
||||
*/
|
||||
final class PasskeyConfigurationWarmerTest extends TestCase
|
||||
{
|
||||
public function test_it_delegates_the_configuration_check(): void
|
||||
{
|
||||
$policy = $this->createMock(PasskeyPolicyInterface::class);
|
||||
$policy->expects(self::once())->method('assertConfigurationIsUsable');
|
||||
|
||||
$warmer = new PasskeyConfigurationWarmer($policy);
|
||||
|
||||
self::assertSame([], $warmer->warmUp('/tmp/cache'));
|
||||
}
|
||||
|
||||
public function test_it_is_not_optional(): void
|
||||
{
|
||||
/* an optional warmer can be skipped, which would defeat the check */
|
||||
$warmer = new PasskeyConfigurationWarmer($this->createStub(PasskeyPolicyInterface::class));
|
||||
|
||||
self::assertFalse($warmer->isOptional());
|
||||
}
|
||||
|
||||
public function test_it_propagates_a_configuration_failure(): void
|
||||
{
|
||||
$policy = $this->createStub(PasskeyPolicyInterface::class);
|
||||
$policy->method('assertConfigurationIsUsable')
|
||||
->willThrowException(new PasskeyConfigurationException('nope'));
|
||||
|
||||
$this->expectException(PasskeyConfigurationException::class);
|
||||
(new PasskeyConfigurationWarmer($policy))->warmUp('/tmp/cache');
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,19 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Clock;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ClockTest extends TestCase
|
||||
{
|
||||
public function testNowReturnsDateTimeImmutable(): void
|
||||
public function test_now_returns_date_time_immutable(): void
|
||||
{
|
||||
$clock = new Clock();
|
||||
$before = new \DateTimeImmutable();
|
||||
$before = new DateTimeImmutable();
|
||||
$now = $clock->now();
|
||||
$after = new \DateTimeImmutable();
|
||||
$after = new DateTimeImmutable();
|
||||
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $now);
|
||||
self::assertInstanceOf(DateTimeImmutable::class, $now);
|
||||
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
|
||||
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Unit\Command;
|
||||
|
||||
use App\Command\GenerateBackupCodesCommand;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
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
|
||||
@@ -25,16 +25,17 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
{
|
||||
$manager = $this->createStub(BackupCodeInterface::class);
|
||||
$manager->method('generate')->willReturn($generatedCodes);
|
||||
|
||||
return $manager;
|
||||
}
|
||||
|
||||
public function testGenerateDefaultCountOutputsCodes(): void
|
||||
public function test_generate_default_count_outputs_codes(): void
|
||||
{
|
||||
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
|
||||
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
|
||||
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub($codes),
|
||||
$this->makePersistCache()
|
||||
$this->makePersistCache(),
|
||||
);
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
@@ -48,7 +49,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testGenerateSpecificCountPassesCountToManager(): void
|
||||
public function test_generate_specific_count_passes_count_to_manager(): void
|
||||
{
|
||||
$manager = $this->createMock(BackupCodeInterface::class);
|
||||
$manager->expects(self::once())
|
||||
@@ -65,7 +66,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
self::assertSame(0, $exit);
|
||||
}
|
||||
|
||||
public function testDefaultCountArgumentIsTen(): void
|
||||
public function test_default_count_argument_is_ten(): void
|
||||
{
|
||||
// the configured default for the count argument should be 10
|
||||
$manager = $this->createMock(BackupCodeInterface::class);
|
||||
@@ -84,7 +85,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testBootsAndPersistsCache(): void
|
||||
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()
|
||||
@@ -92,7 +93,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
// without throwing.
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub(['code1']),
|
||||
$this->makePersistCache()
|
||||
$this->makePersistCache(),
|
||||
);
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
@@ -102,25 +103,25 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
self::assertSame(0, $exit);
|
||||
}
|
||||
|
||||
public function testZeroCodesThrowsException(): void
|
||||
public function test_zero_codes_throws_exception(): void
|
||||
{
|
||||
// count must be a positive integer — zero is rejected
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub([]),
|
||||
$this->makePersistCache()
|
||||
$this->makePersistCache(),
|
||||
);
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$tester->execute(['count' => 0]);
|
||||
}
|
||||
|
||||
public function testCommandNameAndDescriptionAreConfigured(): void
|
||||
public function test_command_name_and_description_are_configured(): void
|
||||
{
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub(['dummy']),
|
||||
$this->makePersistCache()
|
||||
$this->makePersistCache(),
|
||||
);
|
||||
// configuring via the Application runs the protected configure()
|
||||
$app = new \Symfony\Component\Console\Application();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user