Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1335c31d4e | ||
|
|
9523accd23 | ||
|
|
6bbfd44e7d | ||
|
|
ffe6870231 | ||
|
|
11903bf746 | ||
|
|
69ee5e99aa | ||
|
|
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 |
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
|
.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/
|
var/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# ── Rebuilt inside the image from composer.lock ─────────────────────────────
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
# ── Dev / test artefacts not needed at runtime ──────────────────────────────
|
||||||
tests/
|
tests/
|
||||||
.phpunit.cache/
|
.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/
|
docs/
|
||||||
*.md
|
*.md
|
||||||
.env
|
license.txt
|
||||||
.env.test
|
Caddyfile
|
||||||
.env.local
|
Domainfile
|
||||||
|
run.sh
|
||||||
|
deploy.sh
|
||||||
|
bin/composer
|
||||||
|
bin/dev.sh
|
||||||
|
bin/franken.sh
|
||||||
|
bin/phpunit
|
||||||
composer.phar
|
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
|
# editorconfig.org
|
||||||
|
#
|
||||||
|
# Canonical shared .editorconfig. Copy verbatim into a project root.
|
||||||
|
# LEAF file: sync = overwrite, never merge (GUIDING-LIGHT §8.2).
|
||||||
|
#
|
||||||
|
# context-loom was missing this entirely in the 2026-09 audit; the other four
|
||||||
|
# had three subtly different versions.
|
||||||
|
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# YAML is indentation-significant and the rest of the ecosystem uses 2 spaces.
|
||||||
|
[*.{yaml,yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Docker/compose files follow the same convention.
|
||||||
|
[{Dockerfile,*.dockerfile}]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[{compose.yaml,compose.*.yaml,compose.yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Markdown: trailing whitespace is a hard line break in some renderers, so
|
||||||
|
# stripping it silently changes formatting.
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
# Generated / vendored content: never touch, even accidentally on save.
|
||||||
|
[{vendor/**,var/**,node_modules/**,public/bundles/**}]
|
||||||
|
insert_final_newline = false
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
# Caddy's own formatter (caddy fmt) indents with tabs, and every Caddyfile in
|
||||||
|
# the portfolio already uses them — at the root (preauth, penny-track,
|
||||||
|
# vital-pulse), under docker/ (the FrankenPHP app config), and under
|
||||||
|
# docs/examples/ (the edge-proxy reference copied from). Without this rule the
|
||||||
|
# `[*]` block above silently tells editors to use spaces, so every save
|
||||||
|
# reindents the file and caddy fmt immediately undoes it.
|
||||||
[Caddyfile]
|
[Caddyfile]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
[{compose.yaml,compose.*.yaml}]
|
[Makefile]
|
||||||
indent_size = 2
|
indent_style = tab
|
||||||
|
|
||||||
[*.md]
|
[*.{sh,bash}]
|
||||||
trim_trailing_whitespace = false
|
indent_size = 4
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ BURST_COUNT=10
|
|||||||
BURST_TIME=30
|
BURST_TIME=30
|
||||||
UPPER_COUNT=100
|
UPPER_COUNT=100
|
||||||
UPPER_TIME=3600
|
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_PATHS=''
|
||||||
PUBLIC_BURST_COUNT=100
|
PUBLIC_BURST_COUNT=100
|
||||||
PUBLIC_BURST_TIME=60
|
PUBLIC_BURST_TIME=60
|
||||||
|
|||||||
@@ -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
|
name: Push Develop
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
- 'develop'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
|
||||||
|
with:
|
||||||
|
mode: develop
|
||||||
|
|
||||||
steps:
|
# Passed explicitly from repo vars
|
||||||
- name: Checkout
|
image-target: ${{ vars.DOCKERHUB_TARGET }}
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Buildx
|
# docker-bake.hcl controls building
|
||||||
uses: docker/setup-buildx-action@v3
|
build-backend: 'bake'
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
secrets:
|
||||||
uses: docker/login-action@v3
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
with:
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
name: Push Docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -7,31 +11,16 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
|
||||||
|
with:
|
||||||
|
mode: release
|
||||||
|
|
||||||
steps:
|
# Passed explicitly from repo vars
|
||||||
- name: Checkout
|
image-target: ${{ vars.DOCKERHUB_TARGET }}
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Buildx
|
# docker-bake.hcl controls building
|
||||||
uses: docker/setup-buildx-action@v3
|
build-backend: 'bake'
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
secrets:
|
||||||
uses: docker/login-action@v3
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
with:
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
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 }}
|
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
|
# Sync GitHub - upload branch change to github, via the shared workflow
|
||||||
|
|
||||||
name: Sync GitHub
|
name: Sync GitHub
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '**'
|
- 'main'
|
||||||
|
- 'feat*'
|
||||||
|
- 'fix*'
|
||||||
|
- 'cleanup*'
|
||||||
|
- 'chore*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: ubuntu-latest
|
uses: private/ci/.gitea/workflows/sync-github.yaml@v1
|
||||||
|
with:
|
||||||
|
sync-target: ${{ vars.SYNC_GITHUB_TARGET }}
|
||||||
|
|
||||||
steps:
|
# by default we do not alter existing github tags, but that can be changed here.
|
||||||
- name: Checkout
|
# force-tags: true
|
||||||
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
|
|
||||||
|
|
||||||
|
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
|
name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
- 'develop'
|
- 'feat*'
|
||||||
|
- 'fix*'
|
||||||
|
- 'cleanup*'
|
||||||
|
- 'chore*'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
- 'develop'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
uses: private/ci/.gitea/workflows/php-test.yaml@v1
|
||||||
|
with:
|
||||||
|
php-version: '8.5'
|
||||||
|
|
||||||
steps:
|
# profiles defines what to test:
|
||||||
- name: Checkout
|
# * web-app: full test suite (default)
|
||||||
uses: actions/checkout@v4
|
# * auth-gateway: skip template check
|
||||||
|
# * api-gateway: skip interface checks
|
||||||
|
profile: auth-gateway
|
||||||
|
|
||||||
- name: Setup PHP
|
# coverage defines how to check test-coverage:
|
||||||
uses: shivammathur/setup-php@v2
|
# * pcov: recommended (default)
|
||||||
with:
|
# * xdebug
|
||||||
php-version: '8.5'
|
coverage: 'pcov'
|
||||||
extensions: apcu, mbstring
|
# 0-100 percentage of test-coverage required
|
||||||
coverage: xdebug
|
coverage-min: '75'
|
||||||
ini-values: apc.enable_cli=1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
# does failing our "conformance" check make the test suite fail
|
||||||
run: composer install --prefer-dist --no-progress
|
conformance-blocking: false
|
||||||
|
|
||||||
- name: Run php-cs-fixer
|
secrets:
|
||||||
run: vendor/bin/php-cs-fixer fix --dry-run --diff
|
# github token so composer can download dependencies
|
||||||
|
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||||
- name: Run tests
|
|
||||||
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
|
|
||||||
|
|||||||
+68
-13
@@ -1,18 +1,73 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
$finder = (new PhpCsFixer\Finder())
|
declare(strict_types=1);
|
||||||
->in(__DIR__)
|
|
||||||
->exclude('var')
|
|
||||||
->exclude('vendor')
|
|
||||||
->notPath([
|
|
||||||
'config/bundles.php',
|
|
||||||
'config/reference.php',
|
|
||||||
])
|
|
||||||
;
|
|
||||||
|
|
||||||
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([
|
->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)
|
||||||
|
);
|
||||||
|
|||||||
+131
@@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased] — v1.1
|
## [Unreleased] — v1.1
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Passkey authentication (WebAuthn)** — Registered passkeys can replace the
|
||||||
|
TOTP code for everyday logins. Registration itself still requires a valid
|
||||||
|
code, so a passkey can never be created without already holding the secret.
|
||||||
|
- New dependency: `web-auth/webauthn-lib` `^5.3` (resolved to 5.3.9);
|
||||||
|
`composer audit` reports no advisories.
|
||||||
|
- **Requires central auth** (`SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`) so
|
||||||
|
there is one relying party for the whole domain, and **requires HTTPS in
|
||||||
|
every environment, development included**. Enabling it in a configuration
|
||||||
|
that cannot work fails at container start rather than in a browser.
|
||||||
|
- Registration is a checkbox on the login form; login is a button. Passive
|
||||||
|
keys and OS pickers work normally, with the code as a fallback.
|
||||||
|
- New `PasskeyListener` (priority 70) — after the rate-limit gate so a
|
||||||
|
blocked IP never reaches a ceremony, and before `LoginListener` so a
|
||||||
|
ceremony request is not misfiled as a failed login.
|
||||||
|
- New env vars: `PASSKEY_ENABLED`, `PASSKEY_RP_NAME`,
|
||||||
|
`PASSKEY_USER_VERIFICATION`, `PASSKEY_TIMEOUT`, `PASSKEY_BUTTON_NAME`,
|
||||||
|
`PASSKEY_REGISTER_NAME`, `PASSKEY_BEGIN_BURST_COUNT`,
|
||||||
|
`PASSKEY_BEGIN_BURST_TIME`.
|
||||||
|
- New `docs/examples/Caddyfile` section describing local development over
|
||||||
|
real TLS, because there is deliberately no `http://` exemption.
|
||||||
- **Public rate-limited access** — Select paths can now be made publicly
|
- **Public rate-limited access** — Select paths can now be made publicly
|
||||||
accessible without TOTP authentication, with separate per-IP rate limiting.
|
accessible without TOTP authentication, with separate per-IP rate limiting.
|
||||||
This is useful for exposing public content (e.g., public Gitea repositories)
|
This is useful for exposing public content (e.g., public Gitea repositories)
|
||||||
@@ -25,6 +45,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- New `PublicPathMatcher` service for path pattern matching.
|
- New `PublicPathMatcher` service for path pattern matching.
|
||||||
- New `PublicAccessListener` (priority 84) in the request pipeline.
|
- New `PublicAccessListener` (priority 84) in the request pipeline.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Passkey ceremonies** — The challenge is issued and stored server-side and
|
||||||
|
the client's copy is never trusted; it is single-use, deleted before
|
||||||
|
verification so a failed or replayed attempt cannot be retried against the
|
||||||
|
same challenge. Only the derived `https://{AUTH_SUBDOMAIN}` origin is ever
|
||||||
|
accepted, and an unknown credential produces the same response as a wrong
|
||||||
|
code so the endpoint cannot be used for enumeration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Session issuing extracted into `SessionIssuer`** — `LoginManager`
|
||||||
|
previously built the session cookie itself. Both login paths now share one
|
||||||
|
implementation, so a passkey login and a code login set an identical cookie;
|
||||||
|
two copies would have drifted, most likely in cookie attributes, where the
|
||||||
|
difference is invisible until it breaks in a browser.
|
||||||
|
- **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
|
## [1.0.0] — v1.0 Release
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
{
|
||||||
|
frankenphp {
|
||||||
|
# Restart each PHP worker thread after this many requests, containing
|
||||||
|
# slow memory growth across long uptime. Preserves the 7.4-era default
|
||||||
|
# loop count of runtime/frankenphp-symfony (500) after the Symfony 8.1
|
||||||
|
# upgrade. Set MAX_REQUESTS=0 to disable restarts. The Dockerfile bakes
|
||||||
|
# in the default of 500 via build arg; override at runtime with:
|
||||||
|
# docker run -e MAX_REQUESTS=5000 ...
|
||||||
|
# For full control, the stock FRANKENPHP_CONFIG env var can inject any
|
||||||
|
# directive under this block instead.
|
||||||
|
max_requests {$MAX_REQUESTS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
http://
|
http://
|
||||||
root public/
|
root public/
|
||||||
rewrite index.php
|
rewrite index.php
|
||||||
|
|||||||
@@ -8,6 +8,60 @@ This document was originally prepared as a design review. Items that have been a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0. Passkey implementation notes
|
||||||
|
|
||||||
|
Four decisions are worth recording because the reasoning is not obvious from the
|
||||||
|
code, and each looks like an odd choice without it.
|
||||||
|
|
||||||
|
### 0.1 The signature counter is checked leniently, against the library's default
|
||||||
|
|
||||||
|
`webauthn-lib`'s default checker requires a *strictly increasing* counter. That
|
||||||
|
is wrong for the passkeys this feature targets: a synchronised passkey reports a
|
||||||
|
constant `0` forever, so the default rejects a brand-new credential on its
|
||||||
|
**first** login. Measured against the installed version: stored `0`, reported
|
||||||
|
`0` → `CounterException`.
|
||||||
|
|
||||||
|
The failure mode is what makes this worth a note. It cannot happen in a unit
|
||||||
|
test that increments the counter — only on real hardware, and only for the most
|
||||||
|
common kind of passkey. `PasskeyCounterChecker` therefore accepts equal-or-greater
|
||||||
|
and rejects only a counter that moves *backwards*. Clone detection is explicitly
|
||||||
|
not claimed as a property of this feature.
|
||||||
|
|
||||||
|
### 0.2 The ceremony replies are the only browser-facing 2xx
|
||||||
|
|
||||||
|
`SecurityHeadersListener` applies `no-store` to non-2xx responses only, on the
|
||||||
|
assumption that a 2xx is consumed by the proxy's `forward_auth` check. That
|
||||||
|
assumption is false for a ceremony reply: the auth subdomain is `reverse_proxy`-ed
|
||||||
|
with no `forward_auth` in front of it, so the JSON goes straight to the browser.
|
||||||
|
Left alone it would be cacheable, and a browser could replay a stale challenge.
|
||||||
|
|
||||||
|
The producer marks the response (`PasskeyListener` or `LoginManager`) and the
|
||||||
|
caching policy lives in one place that consumes the marker, rather than being
|
||||||
|
duplicated at each site that happens to return 2xx.
|
||||||
|
|
||||||
|
### 0.3 Registration is a checkbox on the login form, not an endpoint
|
||||||
|
|
||||||
|
A separate `register-begin` endpoint was the first design and would have been a
|
||||||
|
vulnerability: it hands out a challenge without proving anything. The TOTP check
|
||||||
|
is what authorises registration, and that check happens inside `LoginManager` as
|
||||||
|
part of an ordinary login submission — so `LoginManager` is where the ceremony
|
||||||
|
starts.
|
||||||
|
|
||||||
|
There is no session cookie to check at that point either, which makes the point
|
||||||
|
neatly: the whole flow is what *produces* the session, so anything gated on one
|
||||||
|
cannot be part of it. The capability at `register-finish` is the single-use
|
||||||
|
ceremony id, issued server-side and bound to the identity that passed the check.
|
||||||
|
|
||||||
|
### 0.4 Both login paths share one session-issuing implementation
|
||||||
|
|
||||||
|
`SessionIssuer` was extracted from `LoginManager` when the passkey ceremony
|
||||||
|
needed the same behaviour. Two implementations would have drifted, and the most
|
||||||
|
likely place to drift is cookie attributes — where a difference is invisible
|
||||||
|
until it breaks in a browser, on one path only. A functional test compares the
|
||||||
|
cookies the two paths produce, field by field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Security
|
## 1. Security
|
||||||
|
|
||||||
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
|
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
|
||||||
|
|||||||
+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
|
FROM php:8.5-trixie AS build
|
||||||
|
|
||||||
# install APCu and composer
|
# Build-time set: git (composer resolves packages over VCS) and unzip (dist
|
||||||
RUN pecl install apcu && \
|
# extraction). Neither reaches the runtime image.
|
||||||
docker-php-ext-enable apcu
|
RUN apt-get update \
|
||||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
&& apt-get install -y --no-install-recommends git unzip \
|
||||||
RUN apt-get update && \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
apt-get install -y unzip git
|
|
||||||
|
|
||||||
# symfony required environment variables
|
# APCu and Composer, both only needed to compile the application.
|
||||||
ENV APP_DEBUG=0
|
RUN pecl install apcu \
|
||||||
ENV APP_ENV=prod
|
&& docker-php-ext-enable apcu
|
||||||
ENV APP_SHARE_DIR=/data/preauth
|
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
|
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
|
# Manifests first so the dependency layer only rebuilds when they change.
|
||||||
RUN composer install --no-dev --optimize-autoloader
|
COPY composer.json composer.lock symfony.lock ./
|
||||||
RUN composer dump-env prod --empty
|
RUN composer install --no-dev --no-interaction --prefer-dist \
|
||||||
|
--optimize-autoloader --no-scripts
|
||||||
|
|
||||||
# start creating final image
|
# Copy the application. .dockerignore keeps vendor/, var/, tests/ and the
|
||||||
FROM dunglas/frankenphp:php8.5-trixie
|
# local env files out of the context; composer install has already run, so
|
||||||
|
# its vendor/ wins.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
# install APCu and curl (needed for healthcheck)
|
# src/ was not in the context when composer install ran, so the authoritative
|
||||||
RUN pecl install apcu && \
|
# classmap has to be rebuilt now that the application code is present.
|
||||||
docker-php-ext-enable apcu
|
#
|
||||||
RUN apt-get update && \
|
# There is deliberately no `composer dump-env` step: preauth does not depend
|
||||||
apt-get install -y --no-install-recommends curl && \
|
# on symfony/dotenv (it is absent from composer.lock), so nothing reads a
|
||||||
rm -rf /var/lib/apt/lists/*
|
# .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
|
# Build-time smoke of the autoloader + config compile. No APP_SECRET is
|
||||||
ENV APP_DEBUG=0
|
# needed: %env(APP_SECRET)% is not resolved at compile time, and the cache is
|
||||||
ENV APP_ENV=prod
|
# cleared afterwards anyway — the real warm-up runs at container start with
|
||||||
ENV APP_SHARE_DIR=/data/preauth
|
# 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
|
WORKDIR /app
|
||||||
COPY --from=build /data/preauth /data/preauth
|
|
||||||
COPY --from=build /app /app
|
COPY --from=build /app /app
|
||||||
|
|
||||||
# configure container
|
# Non-root runtime user (Guiding Light §6.4). uid/gid 1000, same convention
|
||||||
COPY ./Caddyfile /etc/frankenphp/Caddyfile
|
# as task-loom/task-weaver/context-shuttle. /data holds the cache pools the
|
||||||
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
|
# app writes at runtime, /config is Caddy's own XDG dir.
|
||||||
RUN echo 'expose_php = off' > $PHP_INI_DIR/conf.d/restrict.ini
|
RUN groupadd --system --gid 1000 app \
|
||||||
# console needs apc to manage cache
|
&& useradd --system --uid 1000 --gid app \
|
||||||
RUN echo 'apc.enable_cli = on' > $PHP_INI_DIR/conf.d/console.ini
|
--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"]
|
VOLUME ["/config", "/data"]
|
||||||
|
|
||||||
# runs http on standard port
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# healthcheck
|
# Liveness: Caddy's own admin endpoint, bound to loopback inside the
|
||||||
HEALTHCHECK --interval=5m \
|
# container, exactly as the base image declares it (restated here so the
|
||||||
--retries=3 \
|
# probe does not depend on the upstream default staying put). The app's own
|
||||||
--start-interval=1s \
|
# routes cannot serve this: an unauthenticated request gets the login page
|
||||||
--start-period=10s \
|
# with a 401, so `curl -f` against HTTP would always report unhealthy.
|
||||||
--timeout=2s \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
CMD curl http://localhost || exit 1
|
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.
|
||||||
+27
-32
@@ -10,7 +10,7 @@ authentication — it's a gate that prevents outsiders from even seeing
|
|||||||
what service is running.
|
what service is running.
|
||||||
|
|
||||||
- **Location:** `projects/preauth/`
|
- **Location:** `projects/preauth/`
|
||||||
- **Framework:** Symfony 7.4 (PHP ≥ 8.4)
|
- **Framework:** Symfony 8.1 (PHP ≥ 8.4)
|
||||||
- **Serving:** FrankenPHP (Docker image)
|
- **Serving:** FrankenPHP (Docker image)
|
||||||
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
|
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
|
||||||
- **Auth:** TOTP (single secret) + single-use backup codes
|
- **Auth:** TOTP (single secret) + single-use backup codes
|
||||||
@@ -295,6 +295,9 @@ the management surface is incomplete.
|
|||||||
|
|
||||||
### Phase 2c — Passkey Authentication
|
### Phase 2c — Passkey Authentication
|
||||||
|
|
||||||
|
**Status: complete.** See `docs/passkey-auth-subdomain-plan.md` for the full
|
||||||
|
plan, the evidence behind each decision, and the deviations noted below.
|
||||||
|
|
||||||
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
|
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
|
||||||
authentication method alongside TOTP and backup codes.
|
authentication method alongside TOTP and backup codes.
|
||||||
|
|
||||||
@@ -305,39 +308,31 @@ codes. For a pre-auth gate that friends and family use, passkeys would
|
|||||||
be a major UX improvement — especially for non-technical users who
|
be a major UX improvement — especially for non-technical users who
|
||||||
struggle with TOTP apps.
|
struggle with TOTP apps.
|
||||||
|
|
||||||
**Design considerations:**
|
**What was built**, and how it differs from the sketch above:
|
||||||
|
|
||||||
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
|
- **A Symfony bundle was not used**, only `web-auth/webauthn-lib`. The bundle
|
||||||
secret shared with all devices), each device registers its own
|
brings a database-backed credential repository and a controller setup that do
|
||||||
passkey. This is actually better for a family-use gate — you can
|
not fit a no-database, listener-only application; the library alone is a
|
||||||
register mom's phone separately from dad's laptop.
|
clean fit and its types are confined to `PasskeyManager` and
|
||||||
|
`PasskeyCeremonyFactory` so a major-version rename touches two files.
|
||||||
|
- **Registration happens in the browser, not a console command.** The checkbox
|
||||||
|
on the login form is authorised by the TOTP code in the same submission, so
|
||||||
|
it needs no separate token and no CLI. This also settles the "how does the
|
||||||
|
identity get specified" question: it is the identity that just authenticated.
|
||||||
|
- **Central auth is a hard prerequisite.** A passkey is scoped to one relying
|
||||||
|
party, so passkeys require `SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`; the RP ID
|
||||||
|
is always that base domain. Without it the feature stays off, rather than
|
||||||
|
quietly scoping credentials to a single host.
|
||||||
|
- **HTTPS is required with no exemption**, development included, since an
|
||||||
|
`http://` escape hatch is how the same weakness reaches production.
|
||||||
|
- **Failed attempts share the TOTP rate-limit budget**, so passkeys cannot be
|
||||||
|
used to sidestep a lockout.
|
||||||
|
- **Attestation is `none`**, measured rather than assumed — see SECURITY.md and
|
||||||
|
plan §2.3.
|
||||||
|
|
||||||
- WebAuthn requires a **challenge-response flow**:
|
**Remaining work:** none for the feature itself. Discoverable-credential
|
||||||
1. Client requests a challenge (preauth generates and stores a
|
(usernameless) login is possible but not needed, since the login page already
|
||||||
challenge nonce, similar to the existing nonce system)
|
lists registered credentials.
|
||||||
2. Browser prompts for biometric/PIN, creates a signed assertion
|
|
||||||
3. Server verifies the assertion against the registered credential
|
|
||||||
|
|
||||||
- This is a **two-step flow** unlike TOTP's single-step, which means
|
|
||||||
the login page JS and `LoginListener` need to handle an additional
|
|
||||||
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
|
|
||||||
is a good foundation — extend it with a "use passkey" button that
|
|
||||||
initiates the `navigator.credentials.get()` flow.
|
|
||||||
|
|
||||||
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
|
|
||||||
Symfony bundle available). Would add registration ceremony (console
|
|
||||||
command or initial-setup flow to register a passkey).
|
|
||||||
|
|
||||||
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
|
||||||
7.4 and FrankenPHP
|
|
||||||
- [ ] Design passkey registration flow (console command? first-visit
|
|
||||||
setup? separate registration endpoint?)
|
|
||||||
- [ ] Implement challenge generation and storage (extend existing
|
|
||||||
nonce/cache infrastructure)
|
|
||||||
- [ ] Implement assertion verification in a new `PasskeyManager`
|
|
||||||
service (implements a shared `AuthMethodInterface`?)
|
|
||||||
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
|
|
||||||
- [ ] Handle multiple registered passkeys (per-device)
|
|
||||||
- [ ] Console command: `app:list-passkeys` — show registered devices
|
- [ ] Console command: `app:list-passkeys` — show registered devices
|
||||||
- [ ] Console command: `app:remove-passkey` — revoke a passkey
|
- [ ] Console command: `app:remove-passkey` — revoke a passkey
|
||||||
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
|
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### Passkeys (WebAuthn)
|
||||||
|
|
||||||
|
Off by default (`PASSKEY_ENABLED=0`). When on, the following hold:
|
||||||
|
|
||||||
|
- **Registration requires a valid TOTP code.** The checkbox rides on an
|
||||||
|
ordinary login submission, and the code check in that same request is what
|
||||||
|
authorises the ceremony. There is no enrolment token, no CLI path, and no way
|
||||||
|
to create a credential without already holding the secret. The identity comes
|
||||||
|
from the authenticated session, never from the request body.
|
||||||
|
- **The challenge is server-authoritative and single-use.** It is generated and
|
||||||
|
stored server-side; the client's copy is never trusted. The stored record is
|
||||||
|
deleted *before* verification runs, so a failed or replayed attempt cannot be
|
||||||
|
retried against the same challenge. Records live in the in-memory `nonceCache`
|
||||||
|
with a 300-second TTL and deliberately do not survive a restart.
|
||||||
|
- **Only the derived origin is accepted.** The allowed origin is always
|
||||||
|
`https://{AUTH_SUBDOMAIN}`, computed from configuration and never from the
|
||||||
|
request. `http://` is therefore rejected regardless of how the request
|
||||||
|
arrived, and there is no setting that re-enables it. The library's deprecated
|
||||||
|
`setSecuredRelyingPartyId()` escape hatch is not used, and development uses
|
||||||
|
real TLS instead of an exemption.
|
||||||
|
- **The RP ID is the base domain**, so a credential is scoped to every service
|
||||||
|
on that domain. This is the intended behaviour and the reason central auth is
|
||||||
|
a hard prerequisite: without a single shared domain there is no sane RP ID.
|
||||||
|
- **Failures are indistinguishable.** An unknown credential, a bad signature
|
||||||
|
and a wrong origin all produce the same response as a wrong TOTP code, so the
|
||||||
|
endpoint cannot be used to enumerate credentials.
|
||||||
|
- **Failures share the login rate-limit budget.** A failed ceremony costs the
|
||||||
|
same token as a wrong code, and once the limit is reached every method is
|
||||||
|
blocked. Passkeys cannot be used to sidestep a lockout, and the resource guard
|
||||||
|
that bounds ceremony *starts* is deliberately separate, so a legitimate login
|
||||||
|
never spends failure budget.
|
||||||
|
- **The signature counter is not a security control.** Most passkeys —
|
||||||
|
anything synchronised through a keychain — report a constant counter, so a
|
||||||
|
counter-based clone check would lock users out of their own credentials.
|
||||||
|
preauth accepts an unchanged counter and rejects only one that moves
|
||||||
|
*backwards*, which is the only signal the value can carry. **Clone detection is
|
||||||
|
deliberately not a property this feature claims.**
|
||||||
|
- **Attestation is deliberately not requested** (`attestation: 'none'`).
|
||||||
|
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 newer than its cached blob. This was measured rather
|
||||||
|
than assumed; see §2.3 of
|
||||||
|
`docs/passkey-auth-subdomain-plan.md` for the evidence. **Revisit if** a
|
||||||
|
deployment needs to prove which make and model of authenticator is enrolled,
|
||||||
|
or if a policy (rather than a preference) requires attested keys — in which
|
||||||
|
case the metadata service must be pinned and kept current, and the zero-AAGUID
|
||||||
|
case decided explicitly rather than by omission.
|
||||||
|
- **The ceremony replies are not cacheable.** They are the only 2xx this
|
||||||
|
application returns straight to a browser (every other 2xx is consumed by the
|
||||||
|
proxy's `forward_auth` check), so they carry the same anti-caching headers as
|
||||||
|
the rest of the login flow.
|
||||||
|
|
||||||
|
## 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.
|
||||||
+1
-1
@@ -46,7 +46,7 @@ REQUIRED_PHP_EXTS=(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Apt packages for PHP + extensions
|
# Apt packages for PHP + extensions
|
||||||
# Note: preauth uses Symfony 7.4 which requires PHP >=8.1.
|
# Note: preauth uses Symfony 8.1 which requires PHP >=8.4.
|
||||||
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
|
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
|
||||||
PHP_APT_PACKAGES=(
|
PHP_APT_PACKAGES=(
|
||||||
php8.4-cli
|
php8.4-cli
|
||||||
|
|||||||
+1
-3
@@ -9,8 +9,6 @@ docker run --name preauth \
|
|||||||
-e APP_ENV=dev \
|
-e APP_ENV=dev \
|
||||||
-e APP_DEBUG=true \
|
-e APP_DEBUG=true \
|
||||||
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
|
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
|
||||||
-e APP_SHARE_DIR=var/share \
|
-e APP_SHARE_DIR=/app/var/share \
|
||||||
-e DEFAULT_URI=http://localhost \
|
|
||||||
-v ./var/share:/app/var/share \
|
|
||||||
-p 8000:80 \
|
-p 8000:80 \
|
||||||
digitaladapt/preauth:dev
|
digitaladapt/preauth:dev
|
||||||
|
|||||||
+20
-19
@@ -4,22 +4,22 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.4",
|
"php": "^8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"bacon/bacon-qr-code": "^3.1.1",
|
"bacon/bacon-qr-code": "^3.1.1",
|
||||||
"runtime/frankenphp-symfony": "^1.0.0",
|
|
||||||
"spomky-labs/otphp": "^11.4.2",
|
"spomky-labs/otphp": "^11.4.2",
|
||||||
"symfony/cache": "7.4.*",
|
"symfony/cache": "8.1.*",
|
||||||
"symfony/console": "7.4.*",
|
"symfony/console": "8.1.*",
|
||||||
"symfony/flex": "^2.11",
|
"symfony/flex": "^2.11",
|
||||||
"symfony/framework-bundle": "7.4.*",
|
"symfony/framework-bundle": "8.1.*",
|
||||||
"symfony/mime": "7.4.*",
|
"symfony/mime": "8.1.*",
|
||||||
"symfony/rate-limiter": "7.4.*",
|
"symfony/rate-limiter": "8.1.*",
|
||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "8.1.*",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/twig-bundle": "8.1.*",
|
||||||
"symfony/uid": "7.4.*",
|
"symfony/uid": "8.1.*",
|
||||||
"symfony/yaml": "7.4.*"
|
"symfony/yaml": "8.1.*",
|
||||||
|
"web-auth/webauthn-lib": "^5.3"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -28,7 +28,10 @@
|
|||||||
"symfony/runtime": true
|
"symfony/runtime": true
|
||||||
},
|
},
|
||||||
"bump-after-update": true,
|
"bump-after-update": true,
|
||||||
"sort-packages": true
|
"sort-packages": true,
|
||||||
|
"platform": {
|
||||||
|
"php": "8.5.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -66,18 +69,16 @@
|
|||||||
"symfony/symfony": "*"
|
"symfony/symfony": "*"
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"runtime": {
|
|
||||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
|
||||||
},
|
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.4.*"
|
"require": "8.1.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"friendsofphp/php-cs-fixer": "*",
|
"friendsofphp/php-cs-fixer": "^3.95",
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
"phpunit/phpunit": "^13.2",
|
"phpunit/phpunit": "^13.2",
|
||||||
"symfony/browser-kit": "7.4.*",
|
"symfony/browser-kit": "8.1.*",
|
||||||
"symfony/css-selector": "7.4.*"
|
"symfony/css-selector": "8.1.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1929
-784
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ framework:
|
|||||||
adapters: cache.adapter.filesystem
|
adapters: cache.adapter.filesystem
|
||||||
publicRateLimitCache:
|
publicRateLimitCache:
|
||||||
adapters: cache.adapter.apcu
|
adapters: cache.adapter.apcu
|
||||||
|
passkeyRateLimitCache:
|
||||||
|
adapters: cache.adapter.apcu
|
||||||
|
|
||||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||||
prefix_seed: digitaladapt/preauth
|
prefix_seed: digitaladapt/preauth
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
@@ -27,3 +27,15 @@ framework:
|
|||||||
public_limiter:
|
public_limiter:
|
||||||
policy: compound
|
policy: compound
|
||||||
limiters: [public_burst, public_upper]
|
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'
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ framework:
|
|||||||
adapters: cache.adapter.array
|
adapters: cache.adapter.array
|
||||||
publicRateLimitCache:
|
publicRateLimitCache:
|
||||||
adapters: cache.adapter.array
|
adapters: cache.adapter.array
|
||||||
|
passkeyRateLimitCache:
|
||||||
|
adapters: cache.adapter.array
|
||||||
|
|||||||
@@ -15,4 +15,10 @@ twig:
|
|||||||
teapot_message: '%env(TEAPOT_MESSAGE)%'
|
teapot_message: '%env(TEAPOT_MESSAGE)%'
|
||||||
too_many_title: '%env(TOO_MANY_TITLE)%'
|
too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||||
too_many_message: '%env(TOO_MANY_MESSAGE)%'
|
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)%'
|
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
|
<?php
|
||||||
|
|
||||||
if (file_exists(dirname(__DIR__) .
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (file_exists(dirname(__DIR__).
|
||||||
'/var/cache/prod/App_KernelProdContainer.preload.php')
|
'/var/cache/prod/App_KernelProdContainer.preload.php')
|
||||||
) {
|
) {
|
||||||
require dirname(__DIR__) .
|
require dirname(__DIR__).
|
||||||
'/var/cache/prod/App_KernelProdContainer.preload.php';
|
'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ parameters:
|
|||||||
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
|
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
|
||||||
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
|
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 ---
|
# --- styling options ---
|
||||||
env(TITLE): 'Pre-Authentication System'
|
env(TITLE): 'Pre-Authentication System'
|
||||||
env(BG_COLOR): '#029386' # teal
|
env(BG_COLOR): '#029386' # teal
|
||||||
@@ -96,6 +112,15 @@ parameters:
|
|||||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||||
app.too_many_title: '%env(TOO_MANY_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:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
@@ -110,3 +135,15 @@ services:
|
|||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# 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,50 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
@@ -15,6 +15,28 @@
|
|||||||
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
|
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
|
||||||
#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page
|
#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 ---
|
# --- extra options ---
|
||||||
|
|
||||||
# how long do we allow *ALL* traffic from an ip address after successful login
|
# 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"
|
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||||
#TEAPOT=true # default enabled, boolean
|
#TEAPOT=true # default enabled, boolean
|
||||||
|
|
||||||
|
# --- server / worker options ---
|
||||||
|
|
||||||
|
# (container/deployment only) restart each FrankenPHP worker thread after
|
||||||
|
# this many requests, containing memory growth across long uptime;
|
||||||
|
# matching the default from the old runtime/frankenphp-symfony package.
|
||||||
|
# 0 disables restarts. consumed by the Caddyfile, not the PHP app.
|
||||||
|
#MAX_REQUESTS=500 # default 500
|
||||||
|
|
||||||
# --- remote-user header ---
|
# --- remote-user header ---
|
||||||
# Controls the value sent in the Remote-User header on successful auth.
|
# Controls the value sent in the Remote-User header on successful auth.
|
||||||
# session: the session id (default, backward-compatible)
|
# session: the session id (default, backward-compatible)
|
||||||
@@ -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:
|
services:
|
||||||
preauth:
|
preauth:
|
||||||
env_file:
|
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
|
# strongly recommend setting TOTP_URI, if not provided the app
|
||||||
# will generate one for you, please copy it into your .env file
|
# will generate one for you, please copy it into your .env file
|
||||||
- .env
|
- .env
|
||||||
@@ -9,8 +10,10 @@ services:
|
|||||||
- 80
|
- 80
|
||||||
image: digitaladapt/preauth:latest
|
image: digitaladapt/preauth:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# if you wish to set the user, you must make sure that the user
|
# The image runs as the non-root `app` user (uid/gid 1000) and creates
|
||||||
# can write to /config and /data within the container
|
# 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>
|
#user: <uid>:<gid>
|
||||||
volumes:
|
volumes:
|
||||||
- preauth-config:/config
|
- preauth-config:/config
|
||||||
@@ -19,4 +22,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
preauth-config:
|
preauth-config:
|
||||||
preauth-data:
|
preauth-data:
|
||||||
|
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
**Status: complete.** All steps below are implemented and on
|
||||||
|
`feat/passkey-auth-subdomain`. Two deviations from the order as written, both
|
||||||
|
noted inline.
|
||||||
|
|
||||||
|
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`".
|
||||||
|
> **Deviation 1 — done after step 6.** The listener needs the shared session
|
||||||
|
> issuing that step 6 extracts, so the order had to be inverted.
|
||||||
|
>
|
||||||
|
> **Deviation 2 — three operations, not four.** `register-begin` is not a
|
||||||
|
> listener operation, and the first implementation was wrong to make it one.
|
||||||
|
> It would have handed out a challenge without proving anything; there is
|
||||||
|
> also no session cookie to check at that point, since the whole flow is what
|
||||||
|
> produces the session. The ceremony is started by `LoginManager`, after it
|
||||||
|
> verifies the code and nonce. A test pins that the listener refuses the
|
||||||
|
> operation.
|
||||||
|
6. **Extract session issuing** from `LoginManager` so both paths share it —
|
||||||
|
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
|
||||||
|
before touching anything else (Q3.8).
|
||||||
|
> Verified as intended: all 18 existing `LoginManagerTest` cases passed
|
||||||
|
> unchanged, and a functional test now compares the two paths' cookies
|
||||||
|
> field by field.
|
||||||
|
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
|
||||||
|
hand-off described in §3.1, `_passkey_register.html.twig`.
|
||||||
|
> Note: the separate script template was not needed — both handlers share
|
||||||
|
> helpers, so `_passkey.html.twig` holds them and `login.html.twig` stays a
|
||||||
|
> single readable file. The checkbox is also **not** rendered where the form
|
||||||
|
> does not POST, since registration authorises itself with the code carried
|
||||||
|
> in that submission.
|
||||||
|
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,733 @@
|
|||||||
|
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: '#^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: '#^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: 20
|
||||||
|
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
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -46,6 +46,8 @@
|
|||||||
</include>
|
</include>
|
||||||
|
|
||||||
<deprecationTrigger>
|
<deprecationTrigger>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||||
<function>trigger_deprecation</function>
|
<function>trigger_deprecation</function>
|
||||||
</deprecationTrigger>
|
</deprecationTrigger>
|
||||||
</source>
|
</source>
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@ use App\Kernel;
|
|||||||
|
|
||||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
return function (array $context) {
|
return static function (array $context) {
|
||||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ docker pull digitaladapt/preauth:latest
|
|||||||
openssl rand -base64 30
|
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
|
```env
|
||||||
APP_SECRET=your-random-secret-here
|
APP_SECRET=your-random-secret-here
|
||||||
@@ -61,7 +61,7 @@ COOKIE_TTL=2592000
|
|||||||
docker compose up -d
|
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
|
### 4. Configure Caddy
|
||||||
|
|
||||||
@@ -70,13 +70,24 @@ service.example.com {
|
|||||||
forward_auth preauth {
|
forward_auth preauth {
|
||||||
uri {uri}
|
uri {uri}
|
||||||
copy_headers Remote-User
|
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
|
reverse_proxy your-service:80
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/Caddyfile` for more examples, including path-specific protection
|
See `docs/examples/Caddyfile` for more examples, including path-specific protection
|
||||||
and central auth subdomain configuration.
|
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)
|
### 5. Generate backup codes (optional)
|
||||||
|
|
||||||
@@ -95,7 +106,7 @@ capabilities may work, but only Caddy is officially supported.
|
|||||||
|
|
||||||
## Configuration
|
## 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.
|
for the complete reference.
|
||||||
|
|
||||||
### Main Options
|
### Main Options
|
||||||
@@ -113,6 +124,7 @@ for the complete reference.
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). |
|
| `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). |
|
| `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
|
### Remote-User Header
|
||||||
|
|
||||||
@@ -140,6 +152,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
|||||||
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
||||||
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
||||||
|
|
||||||
|
### Passkey Authentication
|
||||||
|
|
||||||
|
Passkeys (WebAuthn) can replace the TOTP code for everyday logins, while the
|
||||||
|
code remains the way a new device is enrolled.
|
||||||
|
|
||||||
|
**Two prerequisites, both enforced.** The feature refuses to operate without
|
||||||
|
them rather than degrading quietly:
|
||||||
|
|
||||||
|
1. **Central auth must be configured** (`SUBDOMAIN_REDIRECT=true` and a real
|
||||||
|
`AUTH_SUBDOMAIN`). A passkey is scoped to one relying party, so there has to
|
||||||
|
be a single shared domain for the whole family of services. Without it,
|
||||||
|
passkeys are switched off — they would otherwise be scoped to a single host
|
||||||
|
and confuse users with multiple, unrelated passkeys.
|
||||||
|
2. **HTTPS is required, in development too.** There is no `http://localhost`
|
||||||
|
exemption and no setting that re-enables one, because such an exemption is
|
||||||
|
exactly how the same weakness ends up enabled in production. To exercise
|
||||||
|
passkeys locally, see the TLS note in `docs/examples/Caddyfile`.
|
||||||
|
|
||||||
|
`localhost` therefore cannot be used for passkeys: it has no base domain, so
|
||||||
|
central auth cannot be configured at all.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PASSKEY_ENABLED` | `0` | Master switch. Enabling it without the prerequisites above makes the container fail at startup, rather than offering a feature that cannot work. |
|
||||||
|
| `PASSKEY_RP_NAME` | `TITLE` | Name shown in the authenticator prompt. |
|
||||||
|
| `PASSKEY_USER_VERIFICATION` | `required` | `required`, `preferred`, or `discouraged`. An unrecognised value falls back to `required`, never to something weaker. |
|
||||||
|
| `PASSKEY_TIMEOUT` | `60000` | Ceremony timeout in milliseconds. |
|
||||||
|
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | Label for the sign-in button. |
|
||||||
|
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | Label for the registration checkbox. |
|
||||||
|
| `PASSKEY_BEGIN_BURST_COUNT` | `30` | Ceremonies one caller may start per window. A resource guard, not part of the login budget. |
|
||||||
|
| `PASSKEY_BEGIN_BURST_TIME` | `60` | Window for the above, in seconds. |
|
||||||
|
|
||||||
|
**Registering a device.** Log in with your code as usual, tick *Register this
|
||||||
|
device as a passkey*, and approve the prompt. The TOTP check in that same
|
||||||
|
submission is what authorises the registration — there is no separate enrolment
|
||||||
|
token and no CLI command, so a passkey cannot be created without already holding
|
||||||
|
a valid code.
|
||||||
|
|
||||||
|
**Logging in.** Once registered, *Sign in with a passkey* signs you in with a
|
||||||
|
fingerprint, face, or device PIN instead of typing a code. Failed passkey
|
||||||
|
attempts count against the same rate-limit budget as wrong codes, so passkeys
|
||||||
|
cannot be used to sidestep a lockout, and after the limit is reached *every*
|
||||||
|
method is blocked equally.
|
||||||
|
|
||||||
|
> **On signature counters.** Many passkeys (anything synchronised through a
|
||||||
|
> keychain) report a constant counter, so a counter-based clone check would lock
|
||||||
|
> users out of their own credentials. Preauth accepts an unchanged counter and
|
||||||
|
> rejects only one that moves *backwards*. Clone detection is deliberately not a
|
||||||
|
> property this feature claims — see `SECURITY.md`.
|
||||||
|
|
||||||
### Public Rate-Limited Access
|
### Public Rate-Limited Access
|
||||||
|
|
||||||
Preauth can provide rate-limited unauthenticated access to select public
|
Preauth can provide rate-limited unauthenticated access to select public
|
||||||
@@ -223,9 +285,17 @@ passes through a priority-ordered chain of listeners:
|
|||||||
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
||||||
allows rate-limited unauthenticated access to matching paths.
|
allows rate-limited unauthenticated access to matching paths.
|
||||||
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
||||||
5. **LoginListener** (priority 66) — Processes login attempts.
|
5. **PasskeyListener** (priority 70) — WebAuthn ceremonies, when enabled.
|
||||||
6. **InterceptListener** (priority 55) — Renders login page or redirects.
|
6. **LoginListener** (priority 66) — Processes login attempts.
|
||||||
7. **SecurityHeadersListener** (response) — Adds security headers.
|
7. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||||
|
8. **SecurityHeadersListener** (response) — Adds security headers.
|
||||||
|
|
||||||
|
`PasskeyListener` sits deliberately between the rate-limit gate and the login
|
||||||
|
handler: **after** `RejectListener`, so a blocked IP never reaches a ceremony;
|
||||||
|
and **before** `LoginListener`, because that listener treats any POST to the auth
|
||||||
|
subdomain as a login attempt, and a ceremony request carries no code — it would
|
||||||
|
otherwise be counted as a failed login and burn rate-limit budget on every
|
||||||
|
legitimate passkey sign-in.
|
||||||
|
|
||||||
### Security Model
|
### Security Model
|
||||||
|
|
||||||
@@ -236,6 +306,21 @@ passes through a priority-ordered chain of listeners:
|
|||||||
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
|
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
|
||||||
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
|
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
|
||||||
Referrer-Policy, HSTS
|
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.
|
||||||
|
Passkey ceremony replies are the one exception: they are the only 2xx
|
||||||
|
this application returns straight to a browser, so they carry the same
|
||||||
|
anti-caching headers.
|
||||||
|
- **Passkeys**: registerable only after a valid TOTP code; challenge is
|
||||||
|
server-issued and single-use; RP ID is always the base domain; only the
|
||||||
|
derived `https://` origin is ever accepted; a failed attempt is
|
||||||
|
indistinguishable from a wrong code and shares its rate-limit budget.
|
||||||
|
|
||||||
### Cache
|
### Cache
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,18 @@ final class AppConstants
|
|||||||
* Also used for cache key truncation.
|
* Also used for cache key truncation.
|
||||||
*/
|
*/
|
||||||
public const int MAX_INPUT_LENGTH = 128;
|
public const int MAX_INPUT_LENGTH = 128;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a response as WebAuthn ceremony output.
|
||||||
|
*
|
||||||
|
* A ceremony reply is the only 2xx this application returns straight to a
|
||||||
|
* browser — every other 2xx is consumed by the reverse proxy's forward_auth
|
||||||
|
* check. So it is the only one that needs the no-store treatment, and this
|
||||||
|
* marker is how `SecurityHeadersListener` recognises it without the caching
|
||||||
|
* policy being duplicated at each site that produces one.
|
||||||
|
*
|
||||||
|
* It lives here rather than on a listener because both `PasskeyListener`
|
||||||
|
* (finish) and `LoginManager` (the registration hand-off) produce them.
|
||||||
|
*/
|
||||||
|
public const string PASSKEY_CEREMONY_MARKER = 'X-Preauth-Ceremony';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
|
|
||||||
|
|
||||||
/** simple console command to generate backup codes
|
/** simple console command to generate backup codes
|
||||||
* usage: php bin/console app:generate-backup-codes [count] */
|
* usage: php bin/console app:generate-backup-codes [count] */
|
||||||
@@ -21,7 +21,7 @@ final class GenerateBackupCodesCommand extends Command
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BackupCodeInterface $manager,
|
private readonly BackupCodeInterface $manager,
|
||||||
private readonly PersistCache $persistCache,
|
private readonly PersistCache $persistCache,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,7 @@ final class GenerateBackupCodesCommand extends Command
|
|||||||
$output->writeln($code);
|
$output->writeln($code);
|
||||||
}
|
}
|
||||||
$this->persistCache->persist();
|
$this->persistCache->persist();
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-19
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App;
|
namespace App;
|
||||||
|
|
||||||
use App\Enum\RemoteUserMode;
|
use App\Enum\RemoteUserMode;
|
||||||
|
use App\Enum\UserVerification;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Psr\Clock\ClockInterface;
|
use Psr\Clock\ClockInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
@@ -23,34 +24,58 @@ final readonly class ConfigBag
|
|||||||
private string $remoteUserStatic;
|
private string $remoteUserStatic;
|
||||||
/** @var array<string,string> */
|
/** @var array<string,string> */
|
||||||
private array $remoteUserMap;
|
private array $remoteUserMap;
|
||||||
|
private string $title;
|
||||||
|
private bool $passkeyEnabled;
|
||||||
|
private string $passkeyRpName;
|
||||||
|
private UserVerification $passkeyUserVerification;
|
||||||
|
private int $passkeyTimeout;
|
||||||
|
private string $passkeyButtonName;
|
||||||
|
private string $passkeyRegisterName;
|
||||||
|
|
||||||
|
/** Passkey ceremony timeout in milliseconds (WebAuthn default). */
|
||||||
|
private const int DEFAULT_PASSKEY_TIMEOUT = 60000;
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Utilities $utilities,
|
Utilities $utilities,
|
||||||
ClockInterface $clock,
|
ClockInterface $clock,
|
||||||
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
|
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
|
||||||
#[Autowire('%app.totp_uri%')] string $totpUri,
|
#[Autowire('%app.totp_uri%')] string $totpUri,
|
||||||
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
||||||
#[Autowire('%app.teapot%')] bool $teapot,
|
#[Autowire('%app.teapot%')] bool $teapot,
|
||||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||||
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
||||||
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
||||||
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
|
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
|
||||||
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
|
#[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,
|
||||||
|
#[Autowire('%app.passkey_button_name%')] string $passkeyButtonName = 'Sign in with a passkey',
|
||||||
|
#[Autowire('%app.passkey_register_name%')] string $passkeyRegisterName = 'Register this device as a passkey',
|
||||||
) {
|
) {
|
||||||
$this->clock = $clock;
|
$this->clock = $clock;
|
||||||
$this->cookieTtl = $cookieTtl;
|
$this->cookieTtl = $cookieTtl;
|
||||||
$this->totpUri = $totpUri ?: $utilities->loadTotp();
|
$this->totpUri = $totpUri ?: $utilities->loadTotp();
|
||||||
$this->ipTtl = $ipTtl ?: null;
|
$this->ipTtl = $ipTtl ?: null;
|
||||||
$this->teapot = $teapot;
|
$this->teapot = $teapot;
|
||||||
$this->errorMessage = $errorMessage;
|
$this->errorMessage = $errorMessage;
|
||||||
$this->teapotTitle = $teapotTitle;
|
$this->teapotTitle = $teapotTitle;
|
||||||
$this->tooManyTitle = $tooManyTitle;
|
$this->tooManyTitle = $tooManyTitle;
|
||||||
|
|
||||||
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
||||||
$this->remoteUserStatic = $remoteUserStatic;
|
$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;
|
||||||
|
$this->passkeyButtonName = $passkeyButtonName;
|
||||||
|
$this->passkeyRegisterName = $passkeyRegisterName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,17 +85,18 @@ final readonly class ConfigBag
|
|||||||
*/
|
*/
|
||||||
private function parseUserMap(string $map): array
|
private function parseUserMap(string $map): array
|
||||||
{
|
{
|
||||||
if ($map === '') {
|
if ('' === $map) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach (explode(',', $map) as $pair) {
|
foreach (explode(',', $map) as $pair) {
|
||||||
$parts = explode(':', trim($pair), 2);
|
$parts = explode(':', trim($pair), 2);
|
||||||
if (count($parts) === 2) {
|
if (2 === \count($parts)) {
|
||||||
$result[trim($parts[0])] = trim($parts[1]);
|
$result[trim($parts[0])] = trim($parts[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,4 +157,51 @@ final readonly class ConfigBag
|
|||||||
{
|
{
|
||||||
return $this->remoteUserMap;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for the "sign in with a passkey" button. */
|
||||||
|
public function passkeyButtonName(): string
|
||||||
|
{
|
||||||
|
return $this->passkeyButtonName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for the "register this device" checkbox. */
|
||||||
|
public function passkeyRegisterName(): string
|
||||||
|
{
|
||||||
|
return $this->passkeyRegisterName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-20
@@ -14,59 +14,74 @@ final class Payload
|
|||||||
public string $id; /* session name, identifying who is logging in */
|
public string $id; /* session name, identifying who is logging in */
|
||||||
public string $token; /* TOTP, typically six digits */
|
public string $token; /* TOTP, typically six digits */
|
||||||
public string $nonce; /* random unique string, to block duplicate submissions */
|
public string $nonce; /* random unique string, to block duplicate submissions */
|
||||||
public bool $json; /* should we return json (for the login page) */
|
public bool $json; /* should we return json (for the login page) */
|
||||||
public Scope $scope; /* type of access being requested */
|
public Scope $scope; /* type of access being requested */
|
||||||
|
|
||||||
public static function decode(string $base64url): ?Payload
|
/**
|
||||||
|
* The caller ticked "register this device as a passkey".
|
||||||
|
*
|
||||||
|
* Carried on the payload rather than handled by the listener, because the
|
||||||
|
* TOTP check is what authorises registration — so the intent has to reach
|
||||||
|
* `LoginManager`, which is where that check (and the nonce check) already
|
||||||
|
* happens. Starting a ceremony any earlier would move nonce validation and
|
||||||
|
* risk spending it twice.
|
||||||
|
*/
|
||||||
|
public bool $register = false;
|
||||||
|
|
||||||
|
public static function decode(string $base64url): ?self
|
||||||
{
|
{
|
||||||
/* convert the base64url into json string */
|
/* convert the base64url into json string */
|
||||||
$base64 = strtr($base64url, '-_', '+/');
|
$base64 = strtr($base64url, '-_', '+/');
|
||||||
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
$base64 .= str_repeat('=', (4 - \strlen($base64) % 4) % 4);
|
||||||
$json = base64_decode($base64, true);
|
$json = base64_decode($base64, true);
|
||||||
if ($json) {
|
if ($json) {
|
||||||
/* convert the json string into real data */
|
/* convert the json string into real data */
|
||||||
$data = json_decode($json);
|
$data = json_decode($json);
|
||||||
if (is_object($data)) {
|
if (\is_object($data)) {
|
||||||
return Payload::create($data);
|
return self::create($data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function load(InputBag $input): ?Payload
|
public static function load(InputBag $input): ?self
|
||||||
{
|
{
|
||||||
/* convert form data into real data */
|
/* convert form data into real data */
|
||||||
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
|
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
|
||||||
return Payload::create((object)[
|
return self::create((object) [
|
||||||
'id' => $input->get('username'),
|
'id' => $input->get('username'),
|
||||||
'nonce' => $input->get('nonce'),
|
'nonce' => $input->get('nonce'),
|
||||||
'token' => $input->get('totp'),
|
'token' => $input->get('totp'),
|
||||||
'json' => false,
|
'register' => $input->get('register'),
|
||||||
|
'json' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function create(object $data): ?Payload
|
public static function create(object $data): ?self
|
||||||
{
|
{
|
||||||
/* if missing required fields id, nonce, or token */
|
/* if missing required fields id, nonce, or token */
|
||||||
if (strlen(trim($data->id ?? '')) < 1 ||
|
if ('' === trim($data->id ?? '')
|
||||||
strlen(trim($data->nonce ?? '')) < 1 ||
|
|| '' === trim($data->nonce ?? '')
|
||||||
strlen(trim($data->token ?? '')) < 1
|
|| '' === trim($data->token ?? '')
|
||||||
) {
|
) {
|
||||||
/* returns null as the input is invalid */
|
/* returns null as the input is invalid */
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* all input is limited */
|
/* all input is limited */
|
||||||
$payload = new Payload();
|
$payload = new self();
|
||||||
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
|
$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->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||||
$payload->json = ($data->json ?? true);
|
$payload->json = ($data->json ?? true);
|
||||||
|
$payload->register = (bool) ($data->register ?? false);
|
||||||
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
||||||
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
|
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||||
|
|
||||||
return Payload::constrict($payload);
|
return self::constrict($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toString(): string
|
public function toString(): string
|
||||||
@@ -74,10 +89,10 @@ final class Payload
|
|||||||
return json_encode($this);
|
return json_encode($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function constrict(Payload $payload): Payload
|
private static function constrict(self $payload): self
|
||||||
{
|
{
|
||||||
/* When scope is None, json will be considered false. */
|
/* When scope is None, json will be considered false. */
|
||||||
if ($payload->scope === Scope::None) {
|
if (Scope::None === $payload->scope) {
|
||||||
$payload->json = false;
|
$payload->json = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -8,6 +8,6 @@ namespace App\Enum;
|
|||||||
enum Scope: string
|
enum Scope: string
|
||||||
{
|
{
|
||||||
case Cookie = 'cookie';
|
case Cookie = 'cookie';
|
||||||
case Ip = 'ip';
|
case Ip = 'ip';
|
||||||
case None = 'none';
|
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 App\Trait\StringTrait;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
|
||||||
@@ -21,9 +22,9 @@ final readonly class AcceptListener
|
|||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private CacheItemPoolInterface $sessionCache,
|
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
|
||||||
private DomainInterface $domainManager,
|
private DomainInterface $domainManager,
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ final readonly class AcceptListener
|
|||||||
{
|
{
|
||||||
/* check if they sent the correct preauth cookie */
|
/* check if they sent the correct preauth cookie */
|
||||||
$cookieName = $this->sessionCookieName($this->domainManager);
|
$cookieName = $this->sessionCookieName($this->domainManager);
|
||||||
if (! $event->getRequest()->cookies->has($cookieName)) {
|
if (!$event->getRequest()->cookies->has($cookieName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +41,13 @@ final readonly class AcceptListener
|
|||||||
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (! $cookie || ! $this->sessionCache->hasItem($cookieKey)) {
|
if (!$cookie || !$this->sessionCache->hasItem($cookieKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* cookie sent corresponds to valid existing session */
|
/* cookie sent corresponds to valid existing session */
|
||||||
$item = $this->sessionCache->getItem($cookieKey);
|
$item = $this->sessionCache->getItem($cookieKey);
|
||||||
if (! $item->isHit()) {
|
if (!$item->isHit()) {
|
||||||
/* race condition: item was removed between hasItem and getItem */
|
/* race condition: item was removed between hasItem and getItem */
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Trait\HasLoggerTrait;
|
|||||||
use App\Trait\StringTrait;
|
use App\Trait\StringTrait;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
|
||||||
@@ -18,8 +19,8 @@ final readonly class AllowListener
|
|||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private CacheItemPoolInterface $sessionCache,
|
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,13 +34,13 @@ final readonly class AllowListener
|
|||||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (! $this->sessionCache->hasItem($ipKey)) {
|
if (!$this->sessionCache->hasItem($ipKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ip address corresponds to valid existing session */
|
/* ip address corresponds to valid existing session */
|
||||||
$item = $this->sessionCache->getItem($ipKey);
|
$item = $this->sessionCache->getItem($ipKey);
|
||||||
if (! $item->isHit()) {
|
if (!$item->isHit()) {
|
||||||
/* race condition: item was removed between hasItem and getItem */
|
/* race condition: item was removed between hasItem and getItem */
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Listener;
|
|||||||
|
|
||||||
use App\ConfigBag;
|
use App\ConfigBag;
|
||||||
use App\Service\DomainInterface;
|
use App\Service\DomainInterface;
|
||||||
|
use App\Service\PasskeyPolicyInterface;
|
||||||
use App\Trait\CookieNameTrait;
|
use App\Trait\CookieNameTrait;
|
||||||
use App\Trait\HasLoggerTrait;
|
use App\Trait\HasLoggerTrait;
|
||||||
use App\Trait\MakeNonceTrait;
|
use App\Trait\MakeNonceTrait;
|
||||||
@@ -26,9 +27,10 @@ final readonly class InterceptListener
|
|||||||
use MakeNonceTrait;
|
use MakeNonceTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
private DomainInterface $domainManager,
|
private DomainInterface $domainManager,
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
|
private PasskeyPolicyInterface $passkeyPolicy,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,29 +41,33 @@ final readonly class InterceptListener
|
|||||||
/* by this point, we know that the request we have is:
|
/* by this point, we know that the request we have is:
|
||||||
* not already authorized, nor already rate-limited,
|
* not already authorized, nor already rate-limited,
|
||||||
* nor submitting login credentials; so redirect or present the login page now */
|
* nor submitting login credentials; so redirect or present the login page now */
|
||||||
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() &&
|
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost()
|
||||||
$this->domainManager->matchesAuth($event->getRequest()->getHost())
|
&& $this->domainManager->matchesAuth($event->getRequest()->getHost())
|
||||||
) {
|
) {
|
||||||
/* host matches base-domain of auth, but not on auth subdomain, redirect */
|
/* host matches base-domain of auth, but not on auth subdomain, redirect */
|
||||||
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
|
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
|
||||||
$event->setResponse(new Response(
|
$event->setResponse(new Response(
|
||||||
'',
|
'',
|
||||||
Response::HTTP_SEE_OTHER,
|
Response::HTTP_SEE_OTHER,
|
||||||
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
|
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"],
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
||||||
$content = $this->twig->render('login.html.twig', [
|
$content = $this->twig->render('login.html.twig', [
|
||||||
'nonce' => $this->makeNonce(),
|
'nonce' => $this->makeNonce(),
|
||||||
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
||||||
|
/* only offered when the feature is usable *for this request* — the
|
||||||
|
* same computation that decides whether the ceremony endpoints
|
||||||
|
* will answer, so the UI cannot offer what the server refuses */
|
||||||
|
'passkeys' => $this->passkeyPolicy->isAvailableFor($event->getRequest()),
|
||||||
]);
|
]);
|
||||||
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
||||||
$this->sessionCookieName($this->domainManager)
|
$this->sessionCookieName($this->domainManager),
|
||||||
);
|
);
|
||||||
$event->setResponse($this->pruneInvalidCookie(new Response(
|
$event->setResponse($this->pruneInvalidCookie(new Response(
|
||||||
$content,
|
$content,
|
||||||
Response::HTTP_UNAUTHORIZED,
|
Response::HTTP_UNAUTHORIZED,
|
||||||
['Content-Type' => 'text/html']
|
['Content-Type' => 'text/html'],
|
||||||
), $hasCookie, $event->getRequest()->getHost()));
|
), $hasCookie, $event->getRequest()->getHost()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +81,7 @@ final readonly class InterceptListener
|
|||||||
$this->sessionCookieDomain($this->domainManager, $host),
|
$this->sessionCookieDomain($this->domainManager, $host),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Cookie::SAMESITE_STRICT
|
Cookie::SAMESITE_STRICT,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\ConfigBag;
|
|||||||
use App\Data\Payload;
|
use App\Data\Payload;
|
||||||
use App\Service\DomainInterface;
|
use App\Service\DomainInterface;
|
||||||
use App\Service\LoginInterface;
|
use App\Service\LoginInterface;
|
||||||
|
use App\Service\PasskeyPolicyInterface;
|
||||||
use App\Trait\CookieNameTrait;
|
use App\Trait\CookieNameTrait;
|
||||||
use App\Trait\HasLoggerTrait;
|
use App\Trait\HasLoggerTrait;
|
||||||
use App\Trait\MakeNonceTrait;
|
use App\Trait\MakeNonceTrait;
|
||||||
@@ -43,28 +44,29 @@ final readonly class LoginListener
|
|||||||
private RateLimiterFactoryInterface $rateLimiter;
|
private RateLimiterFactoryInterface $rateLimiter;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||||
private DomainInterface $domainManager,
|
private DomainInterface $domainManager,
|
||||||
private LoginInterface $loginManager,
|
private LoginInterface $loginManager,
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
|
private PasskeyPolicyInterface $passkeyPolicy,
|
||||||
) {
|
) {
|
||||||
$this->rateLimiter = $rateLimiter;
|
$this->rateLimiter = $rateLimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
||||||
#[AsEventListener(priority: 66)]
|
#[AsEventListener(priority: 66)]
|
||||||
public function onKernelRequest(RequestEvent $event): void
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
{
|
{
|
||||||
$payload = null;
|
$payload = null;
|
||||||
$response = null;
|
$response = null;
|
||||||
|
|
||||||
if ($event->getRequest()->headers->has($this->headerName())) {
|
if ($event->getRequest()->headers->has($this->headerName())) {
|
||||||
/* if request contains our "X-Preauth" header */
|
/* if request contains our "X-Preauth" header */
|
||||||
$data = $event->getRequest()->headers->get($this->headerName());
|
$data = $event->getRequest()->headers->get($this->headerName());
|
||||||
$payload = Payload::decode($data);
|
$payload = Payload::decode($data);
|
||||||
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
|
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST)
|
||||||
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
&& $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
||||||
) {
|
) {
|
||||||
/* if request is a POST to the auth-subdomain */
|
/* if request is a POST to the auth-subdomain */
|
||||||
$payload = Payload::load($event->getRequest()->getPayload());
|
$payload = Payload::load($event->getRequest()->getPayload());
|
||||||
@@ -80,6 +82,7 @@ final readonly class LoginListener
|
|||||||
/* token or backup-code authentication was successful */
|
/* token or backup-code authentication was successful */
|
||||||
if ($response) {
|
if ($response) {
|
||||||
$event->setResponse($response);
|
$event->setResponse($response);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,18 +95,20 @@ final readonly class LoginListener
|
|||||||
$limitReached,
|
$limitReached,
|
||||||
$payload?->json ?? true,
|
$payload?->json ?? true,
|
||||||
$event->getRequest()->getHost(),
|
$event->getRequest()->getHost(),
|
||||||
$this->makeCacheKey($payload?->id ?? '')
|
$this->makeCacheKey($payload?->id ?? ''),
|
||||||
|
$event->getRequest(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function logFailure(Request $request): bool
|
private function logFailure(Request $request): bool
|
||||||
{
|
{
|
||||||
$limiter = $this->rateLimiter->create($request->getClientIp());
|
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||||
return ($limiter->consume(1)->getRemainingTokens() < 1);
|
|
||||||
|
return $limiter->consume(1)->getRemainingTokens() < 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||||
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
|
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username, Request $request): Response
|
||||||
{
|
{
|
||||||
if ($limited) {
|
if ($limited) {
|
||||||
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
|
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
|
||||||
@@ -111,24 +116,25 @@ final readonly class LoginListener
|
|||||||
$message = $this->config->teapot() ? $this->config->teapotTitle()
|
$message = $this->config->teapot() ? $this->config->teapotTitle()
|
||||||
: $this->config->tooManyTitle();
|
: $this->config->tooManyTitle();
|
||||||
} else {
|
} else {
|
||||||
$status = Response::HTTP_UNAUTHORIZED;
|
$status = Response::HTTP_UNAUTHORIZED;
|
||||||
$message = $this->config->errorMessage();
|
$message = $this->config->errorMessage();
|
||||||
}
|
}
|
||||||
$answer = [
|
$answer = [
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'nonce' => $this->makeNonce(),
|
'nonce' => $this->makeNonce(),
|
||||||
'post' => $this->domainManager->getAuthSubdomain() === $host,
|
'post' => $this->domainManager->getAuthSubdomain() === $host,
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
|
'passkeys' => $this->passkeyPolicy->isAvailableFor($request),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($json) {
|
if ($json) {
|
||||||
$contentType = 'application/json';
|
$contentType = 'application/json';
|
||||||
$content = json_encode($answer);
|
$content = json_encode($answer);
|
||||||
} else {
|
} else {
|
||||||
$contentType = 'text/html';
|
$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,278 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listener;
|
||||||
|
|
||||||
|
use App\AppConstants;
|
||||||
|
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\HasLoggerTrait;
|
||||||
|
use App\Trait\MakeNonceTrait;
|
||||||
|
use App\Trait\StringTrait;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* **Three operations, not four.** `register-begin` is deliberately *absent*: a
|
||||||
|
* registration ceremony may only be started after a valid TOTP code, which is
|
||||||
|
* presented to `LoginManager` as part of the form submission. `LoginManager`
|
||||||
|
* therefore starts that ceremony and returns its options with the login
|
||||||
|
* response. Exposing `register-begin` here would be a way to obtain a challenge
|
||||||
|
* **without proving anything**, which is the vulnerability rather than the
|
||||||
|
* feature — there is no session cookie to check at that point either, since the
|
||||||
|
* whole flow is what *produces* the session.
|
||||||
|
*
|
||||||
|
* **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 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';
|
||||||
|
|
||||||
|
public const string BEGIN_LOGIN = 'login-begin';
|
||||||
|
|
||||||
|
public const string FINISH_LOGIN = 'login-finish';
|
||||||
|
|
||||||
|
public 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,
|
||||||
|
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::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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ceremony was authorised by the TOTP-gated hand-off in `LoginManager`,
|
||||||
|
* so the capability here is the single-use `ceremonyId` itself: it is
|
||||||
|
* server-issued, stored against the identity that passed the check, and
|
||||||
|
* consumed on use.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(AppConstants::PASSKEY_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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,8 @@ final readonly class PublicAccessListener
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private PublicPathMatcherInterface $pathMatcher,
|
private PublicPathMatcherInterface $pathMatcher,
|
||||||
private DomainInterface $domainManager,
|
private DomainInterface $domainManager,
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||||
) {
|
) {
|
||||||
$this->rateLimiter = $rateLimiter;
|
$this->rateLimiter = $rateLimiter;
|
||||||
@@ -63,7 +63,7 @@ final readonly class PublicAccessListener
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->pathMatcher->matches($host, $path)) {
|
if (!$this->pathMatcher->matches($host, $path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ final readonly class PublicAccessListener
|
|||||||
'',
|
'',
|
||||||
Response::HTTP_OK,
|
Response::HTTP_OK,
|
||||||
[
|
[
|
||||||
'Content-Type' => 'text/plain',
|
'Content-Type' => 'text/plain',
|
||||||
'Retry-After' => (string) $limit->getRemainingTokens(),
|
'Retry-After' => (string) $limit->getRemainingTokens(),
|
||||||
],
|
],
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
@@ -92,7 +92,7 @@ final readonly class PublicAccessListener
|
|||||||
Response::HTTP_TOO_MANY_REQUESTS,
|
Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
[
|
[
|
||||||
'Content-Type' => 'text/html',
|
'Content-Type' => 'text/html',
|
||||||
'Retry-After' => (string) $retryAfter,
|
'Retry-After' => (string) $retryAfter,
|
||||||
],
|
],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use App\Trait\HasLoggerTrait;
|
|||||||
use App\Trait\StringTrait;
|
use App\Trait\StringTrait;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
use Twig\Error\LoaderError;
|
use Twig\Error\LoaderError;
|
||||||
@@ -25,8 +25,8 @@ final readonly class RejectListener
|
|||||||
private RateLimiterFactoryInterface $rateLimiter;
|
private RateLimiterFactoryInterface $rateLimiter;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ConfigBag $config,
|
private ConfigBag $config,
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||||
) {
|
) {
|
||||||
$this->rateLimiter = $rateLimiter;
|
$this->rateLimiter = $rateLimiter;
|
||||||
@@ -43,9 +43,9 @@ final readonly class RejectListener
|
|||||||
$html = $this->twig->render('error.html.twig');
|
$html = $this->twig->render('error.html.twig');
|
||||||
$event->setResponse(new Response(
|
$event->setResponse(new Response(
|
||||||
$html,
|
$html,
|
||||||
($this->config->teapot()
|
$this->config->teapot()
|
||||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
['Content-Type' => 'text/html']
|
['Content-Type' => 'text/html'],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
|
|
||||||
|
use App\AppConstants;
|
||||||
use App\Service\DomainInterface;
|
use App\Service\DomainInterface;
|
||||||
|
use App\Service\PasskeyPolicyInterface;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,18 +21,19 @@ final readonly class SecurityHeadersListener
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private DomainInterface $domainManager,
|
private DomainInterface $domainManager,
|
||||||
|
private PasskeyPolicyInterface $passkeyPolicy,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[AsEventListener(priority: 0)]
|
#[AsEventListener(priority: 0)]
|
||||||
public function onKernelResponse(ResponseEvent $event): void
|
public function onKernelResponse(ResponseEvent $event): void
|
||||||
{
|
{
|
||||||
if (! $event->isMainRequest()) {
|
if (!$event->isMainRequest()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $event->getResponse();
|
$response = $event->getResponse();
|
||||||
$headers = $response->headers;
|
$headers = $response->headers;
|
||||||
|
|
||||||
/* prevent MIME-type sniffing */
|
/* prevent MIME-type sniffing */
|
||||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||||
@@ -53,9 +57,16 @@ final readonly class SecurityHeadersListener
|
|||||||
* work. On the auth subdomain the form POSTs normally and no
|
* work. On the auth subdomain the form POSTs normally and no
|
||||||
* inline script is included, so the stricter policy applies. */
|
* inline script is included, so the stricter policy applies. */
|
||||||
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
|
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
|
||||||
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
|
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
|
||||||
|
|
||||||
if ($inlineScript) {
|
/* `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';";
|
$csp .= " connect-src 'self';";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,5 +74,43 @@ final readonly class SecurityHeadersListener
|
|||||||
|
|
||||||
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
||||||
$headers->set('Strict-Transport-Security', 'max-age=31536000');
|
$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(AppConstants::PASSKEY_CEREMONY_MARKER)) {
|
||||||
|
$headers->remove(AppConstants::PASSKEY_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;
|
$this->cache = $cache;
|
||||||
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if (! $item->isHit()) {
|
if (!$item->isHit()) {
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
public function getKeys(): array
|
public function getKeys(): array
|
||||||
{
|
{
|
||||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||||
|
|
||||||
return array_keys($keyList->get() ?? []);
|
return array_keys($keyList->get() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
public function getChanges(): array
|
public function getChanges(): array
|
||||||
{
|
{
|
||||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||||
|
|
||||||
return $changeList->get() ?? [];
|
return $changeList->get() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +92,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
public function clear(): bool
|
public function clear(): bool
|
||||||
{
|
{
|
||||||
/* only bother clearing the pool if it is not empty */
|
/* only bother clearing the pool if it is not empty */
|
||||||
if (! empty($this->getKeys())) {
|
if (!empty($this->getKeys())) {
|
||||||
$response = $this->cache->clear();
|
$response = $this->cache->clear();
|
||||||
|
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +113,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
unset($keyValues[$key]);
|
unset($keyValues[$key]);
|
||||||
$keyList->set($keyValues);
|
$keyList->set($keyValues);
|
||||||
$this->cache->saveDeferred($keyList);
|
$this->cache->saveDeferred($keyList);
|
||||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
$this->logChange($key, self::REMOVED);
|
||||||
$this->cache->commit();
|
$this->cache->commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +129,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
foreach ($keys as $key) {
|
foreach ($keys as $key) {
|
||||||
if (isset($keyValues[$key])) {
|
if (isset($keyValues[$key])) {
|
||||||
unset($keyValues[$key]);
|
unset($keyValues[$key]);
|
||||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
$this->logChange($key, self::REMOVED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$keyList->set($keyValues);
|
$keyList->set($keyValues);
|
||||||
@@ -139,6 +143,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
public function save(CacheItemInterface $item): bool
|
public function save(CacheItemInterface $item): bool
|
||||||
{
|
{
|
||||||
$this->update($item);
|
$this->update($item);
|
||||||
|
|
||||||
return $this->cache->save($item);
|
return $this->cache->save($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +151,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
public function saveDeferred(CacheItemInterface $item): bool
|
public function saveDeferred(CacheItemInterface $item): bool
|
||||||
{
|
{
|
||||||
$this->update($item);
|
$this->update($item);
|
||||||
|
|
||||||
return $this->cache->saveDeferred($item);
|
return $this->cache->saveDeferred($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,27 +177,23 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
|||||||
/** @throws OutOfBoundsException */
|
/** @throws OutOfBoundsException */
|
||||||
private function isValid(string $key): void
|
private function isValid(string $key): void
|
||||||
{
|
{
|
||||||
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
|
if (self::KEY_LIST === $key || self::CHANGE_LIST === $key) {
|
||||||
throw new OutOfBoundsException(
|
throw new OutOfBoundsException('Can not modify the private key or change lists');
|
||||||
'Can not modify the private key or change lists'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws OutOfBoundsException */
|
/** @throws OutOfBoundsException */
|
||||||
private function allValid(array $keys): void
|
private function allValid(array $keys): void
|
||||||
{
|
{
|
||||||
if (in_array(self::KEY_LIST, $keys, true) ||
|
if (\in_array(self::KEY_LIST, $keys, true)
|
||||||
in_array(self::CHANGE_LIST, $keys, true)
|
|| \in_array(self::CHANGE_LIST, $keys, true)
|
||||||
) {
|
) {
|
||||||
throw new OutOfBoundsException(
|
throw new OutOfBoundsException('Can not modify the private key or change lists');
|
||||||
'Can not modify the private key or change lists'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void
|
private function logChange(string $key, int $code = self::UPDATED): void
|
||||||
{
|
{
|
||||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||||
$changeValues = $changeList->get();
|
$changeValues = $changeList->get();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App;
|
|||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
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() */
|
/* need autoconfigure so we get it from the service container in Kernel->boot() */
|
||||||
#[Autoconfigure(public: true)]
|
#[Autoconfigure(public: true)]
|
||||||
@@ -17,10 +18,10 @@ final readonly class PersistCache
|
|||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
CacheItemPoolInterface $sessionCache,
|
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||||
CacheItemPoolInterface $sessionStorage,
|
#[Target('sessionStorage')] CacheItemPoolInterface $sessionStorage,
|
||||||
) {
|
) {
|
||||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||||
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
|
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,22 @@ use Psr\Cache\InvalidArgumentException;
|
|||||||
* they are single-use and marked as used after successful authentication */
|
* they are single-use and marked as used after successful authentication */
|
||||||
interface BackupCodeInterface
|
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
|
* @param int $count Number of codes to generate
|
||||||
|
*
|
||||||
* @return string[] Generated backup codes
|
* @return string[] Generated backup codes
|
||||||
|
*
|
||||||
* @throws InvalidArgumentException|Exception */
|
* @throws InvalidArgumentException|Exception */
|
||||||
public function generate(int $count = 10): array;
|
public function generate(int $count = 10): array;
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function expire(): void;
|
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
|
* @param string $code Code supplied by the client
|
||||||
|
*
|
||||||
* @return bool true if the code is valid and unused
|
* @return bool true if the code is valid and unused
|
||||||
|
*
|
||||||
* @throws InvalidArgumentException */
|
* @throws InvalidArgumentException */
|
||||||
public function verifyAndConsume(string $code): bool;
|
public function verifyAndConsume(string $code): bool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ namespace App\Service;
|
|||||||
|
|
||||||
use App\AppConstants;
|
use App\AppConstants;
|
||||||
use App\MonitorCacheKeys;
|
use App\MonitorCacheKeys;
|
||||||
|
use App\Trait\GetTotpTrait;
|
||||||
use App\Trait\HasLoggerTrait;
|
use App\Trait\HasLoggerTrait;
|
||||||
use App\Trait\StringTrait;
|
use App\Trait\StringTrait;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use App\Trait\GetTotpTrait;
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
|
|
||||||
/** backup-codes are case‑insensitive alphanumeric strings
|
/** backup-codes are case‑insensitive alphanumeric strings
|
||||||
* they are single-use and marked as used after successful authentication */
|
* they are single-use and marked as used after successful authentication */
|
||||||
@@ -29,27 +30,31 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
|||||||
private CacheItemPoolInterface $sessionCache;
|
private CacheItemPoolInterface $sessionCache;
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(CacheItemPoolInterface $sessionCache)
|
public function __construct(
|
||||||
{
|
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||||
|
) {
|
||||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** generate a set of backup-codes and return them
|
/** generate a set of backup-codes and return them.
|
||||||
* @param int $count Number of codes to generate
|
* @param int $count Number of codes to generate
|
||||||
|
*
|
||||||
* @return string[] Generated backup codes
|
* @return string[] Generated backup codes
|
||||||
|
*
|
||||||
* @throws InvalidArgumentException|Exception */
|
* @throws InvalidArgumentException|Exception */
|
||||||
public function generate(int $count = self::DEFAULT_COUNT): array
|
public function generate(int $count = self::DEFAULT_COUNT): array
|
||||||
{
|
{
|
||||||
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
|
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
|
||||||
$codes = [];
|
$codes = [];
|
||||||
for ($i = 0; $i < $count; $i++) {
|
for ($i = 0; $i < $count; ++$i) {
|
||||||
/* output is alphanumeric string of given length */
|
/* output is alphanumeric string of given length */
|
||||||
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
|
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
|
||||||
random_bytes($length)
|
random_bytes($length),
|
||||||
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
|
), 16, 36), 0, $length), $length, '0', \STR_PAD_LEFT));
|
||||||
}
|
}
|
||||||
$this->saveCodes($codes);
|
$this->saveCodes($codes);
|
||||||
$this->logger->info("generated {$count} backup codes");
|
$this->logger->info("generated {$count} backup codes");
|
||||||
|
|
||||||
return $codes;
|
return $codes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,35 +67,38 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
|||||||
$itemsToRemove[] = $key;
|
$itemsToRemove[] = $key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count($itemsToRemove) > 0) {
|
if (\count($itemsToRemove) > 0) {
|
||||||
$this->sessionCache->deleteItems($itemsToRemove);
|
$this->sessionCache->deleteItems($itemsToRemove);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** check if backup-code is valid and mark it as used
|
/** check if backup-code is valid and mark it as used.
|
||||||
* @param string $code Code supplied by the client
|
* @param string $code Code supplied by the client
|
||||||
|
*
|
||||||
* @return bool true if the code is valid and unused
|
* @return bool true if the code is valid and unused
|
||||||
|
*
|
||||||
* @throws InvalidArgumentException */
|
* @throws InvalidArgumentException */
|
||||||
public function verifyAndConsume(string $code): bool
|
public function verifyAndConsume(string $code): bool
|
||||||
{
|
{
|
||||||
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
|
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
|
||||||
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
|
$backupKey = 'backup_'.preg_replace('/[^a-z0-9]+/', '', strtolower($code));
|
||||||
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
|
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
|
||||||
$this->logger->debug('checking backup code: ' . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
|
$this->logger->debug('checking backup code: '.($backupItem->isHit() ? 'HIT & ' : 'miss & ').($backupItem->get() ? 'VALID' : 'invalid'));
|
||||||
if ($backupItem->isHit() && $backupItem->get()) {
|
if ($backupItem->isHit() && $backupItem->get()) {
|
||||||
$this->logger->debug("valid backup code");
|
$this->logger->debug('valid backup code');
|
||||||
/* mark backup code as spent */
|
/* mark backup code as spent */
|
||||||
$backupItem->set(false); /* used */
|
$backupItem->set(false); /* used */
|
||||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||||
* we want this to keep forever, so a few hundred years should do it */
|
* we want this to keep forever, so a few hundred years should do it */
|
||||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d',
|
'Y-m-d',
|
||||||
AppConstants::FAR_FUTURE_DATE
|
AppConstants::FAR_FUTURE_DATE,
|
||||||
));
|
));
|
||||||
$this->sessionCache->save($backupItem);
|
$this->sessionCache->save($backupItem);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
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 */
|
* we want this to keep forever, so a few hundred years should do it */
|
||||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d',
|
'Y-m-d',
|
||||||
AppConstants::FAR_FUTURE_DATE
|
AppConstants::FAR_FUTURE_DATE,
|
||||||
));
|
));
|
||||||
$this->sessionCache->saveDeferred($backupItem);
|
$this->sessionCache->saveDeferred($backupItem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ namespace App\Service;
|
|||||||
|
|
||||||
interface DomainInterface
|
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 */
|
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||||
public function getAuthSubdomain(): ?string;
|
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
|
* @param string $url Where we are thinking of sending the user
|
||||||
|
*
|
||||||
* @return bool Returns true if it is acceptable to send the user there */
|
* @return bool Returns true if it is acceptable to send the user there */
|
||||||
public function validReturn(string $url): bool;
|
public function validReturn(string $url): bool;
|
||||||
|
|
||||||
/** check if host-base matches auth-base
|
/** check if host-base matches auth-base.
|
||||||
* @param string $host
|
|
||||||
* @return bool returns true if and only if host matches base domain of auth */
|
* @return bool returns true if and only if host matches base domain of auth */
|
||||||
public function matchesAuth(string $host): bool;
|
public function matchesAuth(string $host): bool;
|
||||||
|
|
||||||
/** IE: "example.com" if central auth is something like "auth.example.com"
|
/** IE: "example.com" if central auth is something like "auth.example.com".
|
||||||
* @return string|null returns base domain if we are doing central auth */
|
* @return string|null returns base domain if we are doing central auth */
|
||||||
public function authBase(): ?string;
|
public function authBase(): ?string;
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-112
@@ -10,100 +10,100 @@ final readonly class DomainManager implements DomainInterface
|
|||||||
{
|
{
|
||||||
/* top-level-domains which are known to have multiple parts */
|
/* top-level-domains which are known to have multiple parts */
|
||||||
private const array TLD = [
|
private const array TLD = [
|
||||||
'ai' => ['com','net','off','org'],
|
'ai' => ['com', 'net', 'off', 'org'],
|
||||||
'am' => ['radio'],
|
'am' => ['radio'],
|
||||||
'at' => ['ac','co','gv','or'],
|
'at' => ['ac', 'co', 'gv', 'or'],
|
||||||
'au' => ['com','net','org','edu','gov','asn','id'],
|
'au' => ['com', 'net', 'org', 'edu', 'gov', 'asn', 'id'],
|
||||||
'az' => ['com','net','org'],
|
'az' => ['com', 'net', 'org'],
|
||||||
'bd' => ['com','net','org','gov','mil','ac'],
|
'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'],
|
'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'],
|
'by' => ['com', 'net', 'org', 'gov', 'mil', 'of'],
|
||||||
'ca' => ['ab','bc','mb','nb','nf','nl','ns','nt','nu','on','pe','qc','sk','yk'],
|
'ca' => ['ab', 'bc', 'mb', 'nb', 'nf', 'nl', 'ns', 'nt', 'nu', 'on', 'pe', 'qc', 'sk', 'yk'],
|
||||||
'cc' => [],
|
'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'],
|
'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'],
|
'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'],
|
'com' => ['br', 'cn', 'co', 'de', 'eu', 'gr', 'it', 'jpn', 'mex', 'ru', 'sa', 'uk', 'us', 'za', 'au', 'bh', 'bo', 'cn', 'ec', 'eg', 'gt', 'hk', 'hn', 'il', 'in', 'jp', 'kr', 'kw', 'lb', 'lv', 'my', 'mx', 'ng', 'ni', 'np', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pr', 'py', 'sa', 'sg', 'sv', 'tr', 'tw', 'ua', 'uy', 've', 'vn', 'ye'],
|
||||||
'de' => ['com'],
|
'de' => ['com'],
|
||||||
'dk' => ['co'],
|
'dk' => ['co'],
|
||||||
'ec' => ['com','net','org','gov','mil','edu','fin','med','pro'],
|
'ec' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'fin', 'med', 'pro'],
|
||||||
'ee' => ['com','org','pri'],
|
'ee' => ['com', 'org', 'pri'],
|
||||||
'eg' => ['com','net','org','gov','edu','mil'],
|
'eg' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
'es' => ['com','nom','org','edu','gob'],
|
'es' => ['com', 'nom', 'org', 'edu', 'gob'],
|
||||||
'eu' => [],
|
'eu' => [],
|
||||||
'fi' => ['aland'],
|
'fi' => ['aland'],
|
||||||
'fm' => ['radio'],
|
'fm' => ['radio'],
|
||||||
'fr' => ['com','nom','tm','asso','gouv','pol'],
|
'fr' => ['com', 'nom', 'tm', 'asso', 'gouv', 'pol'],
|
||||||
'ge' => ['com','net','org','edu','gov','mil'],
|
'ge' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
|
||||||
'gg' => ['co','net','org'],
|
'gg' => ['co', 'net', 'org'],
|
||||||
'gr' => ['com','net','org','gov','edu','mil'],
|
'gr' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
'hk' => ['com','net','org','gov','edu','idv'],
|
'hk' => ['com', 'net', 'org', 'gov', 'edu', 'idv'],
|
||||||
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'],
|
'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'],
|
'id' => ['ac', 'biz', 'co', 'desa', 'go', 'mil', 'my', 'net', 'or', 'sch', 'web'],
|
||||||
'ie' => ['gov'],
|
'ie' => ['gov'],
|
||||||
'il' => ['ac','co','gov','idf','k12','muni','net','org'],
|
'il' => ['ac', 'co', 'gov', 'idf', 'k12', 'muni', 'net', 'org'],
|
||||||
'in' => ['co','firm','gen','ind','net','org','ac','edu','res','gov','mil'],
|
'in' => ['co', 'firm', 'gen', 'ind', 'net', 'org', 'ac', 'edu', 'res', 'gov', 'mil'],
|
||||||
'iq' => ['com','net','org','gov','edu','mil'],
|
'iq' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
'ir' => ['ac','co','gov','id','net','org','sch'],
|
'ir' => ['ac', 'co', 'gov', 'id', 'net', 'org', 'sch'],
|
||||||
'is' => ['net','com','org','edu','gov','int'],
|
'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'],
|
'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'],
|
'je' => ['co', 'net', 'org'],
|
||||||
'jo' => ['com','net','org','gov','edu','mil','sch'],
|
'jo' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch'],
|
||||||
'jp' => ['ac','ad','co','ed','go','gr','lg','ne','or'],
|
'jp' => ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
|
||||||
'ke' => ['co','ne','or','ac','go','me','mobi','info','sc','pro'],
|
'ke' => ['co', 'ne', 'or', 'ac', 'go', 'me', 'mobi', 'info', 'sc', 'pro'],
|
||||||
'kg' => ['com','net','org','gov','mil','edu'],
|
'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'],
|
'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'],
|
'kz' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
|
||||||
'li' => [],
|
'li' => [],
|
||||||
'lt' => ['gov'],
|
'lt' => ['gov'],
|
||||||
'lv' => ['com','net','org','edu','gov','mil','id','asn','conf'],
|
'lv' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'id', 'asn', 'conf'],
|
||||||
'ly' => ['com','net','org','gov','edu','sch','med','id'],
|
'ly' => ['com', 'net', 'org', 'gov', 'edu', 'sch', 'med', 'id'],
|
||||||
'ma' => ['co','net','org','gov','press','ac'],
|
'ma' => ['co', 'net', 'org', 'gov', 'press', 'ac'],
|
||||||
'mk' => ['com','net','org','edu','gov','inf','name','pro'],
|
'mk' => ['com', 'net', 'org', 'edu', 'gov', 'inf', 'name', 'pro'],
|
||||||
'mx' => ['com','net','org','gov','edu','mil'],
|
'mx' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
'my' => ['com','net','org','gov','edu','mil','name'],
|
'my' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
|
||||||
'na' => ['com','net','org','alt','edu','gov','mil','pro'],
|
'na' => ['com', 'net', 'org', 'alt', 'edu', 'gov', 'mil', 'pro'],
|
||||||
'net' => ['gb','hu','in','jp','se','uk','cn','nz'],
|
'net' => ['gb', 'hu', 'in', 'jp', 'se', 'uk', 'cn', 'nz'],
|
||||||
'ng' => ['com','net','org','gov','edu','mil','sch','name','gov'],
|
'ng' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name', 'gov'],
|
||||||
'ni' => ['ac','co','com','edu','gob','mil','net','nom','org'],
|
'ni' => ['ac', 'co', 'com', 'edu', 'gob', 'mil', 'net', 'nom', 'org'],
|
||||||
'nl' => ['bv','co'],
|
'nl' => ['bv', 'co'],
|
||||||
'no' => ['fhs','folkebibl','kommune','mil','stat','priv','vgs','dep','kommune'],
|
'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'],
|
'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'],
|
'om' => ['com', 'net', 'org', 'gov', 'edu', 'med', 'mil', 'sch'],
|
||||||
'org' => ['ae','us','lu'],
|
'org' => ['ae', 'us', 'lu'],
|
||||||
'pe' => ['com','net','org','gob','edu','mil','nom'],
|
'pe' => ['com', 'net', 'org', 'gob', 'edu', 'mil', 'nom'],
|
||||||
'ph' => ['com','net','org','gov','edu','mil'],
|
'ph' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
|
||||||
'pk' => ['com','net','org','fam','biz','edu','gov','web'],
|
'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'],
|
'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'],
|
'pr' => ['ac', 'co', 'edu', 'gov', 'info', 'island', 'pro', 'net', 'org'],
|
||||||
'pt' => ['com','net','org','gov','edu','int','publ'],
|
'pt' => ['com', 'net', 'org', 'gov', 'edu', 'int', 'publ'],
|
||||||
'py' => ['com','net','org','gov','edu','mil','co'],
|
'py' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'co'],
|
||||||
'qa' => ['com','net','org','gov','edu','mil','sch','name'],
|
'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'],
|
'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'],
|
'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'],
|
'sa' => ['com', 'net', 'org', 'gov', 'med', 'pub', 'edu', 'sch'],
|
||||||
'sb' => ['com','net','org','edu','gov'],
|
'sb' => ['com', 'net', 'org', 'edu', 'gov'],
|
||||||
'sc' => ['com','net','org','gov','edu'],
|
'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'],
|
'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'],
|
'sg' => ['com', 'net', 'org', 'gov', 'edu', 'per'],
|
||||||
'sh' => ['com','net','org','gov','mil','edu'],
|
'sh' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
|
||||||
'sk' => ['co','com','edu','gov','mil','net','org','nfo'],
|
'sk' => ['co', 'com', 'edu', 'gov', 'mil', 'net', 'org', 'nfo'],
|
||||||
'st' => ['co','com','consulado','edu','embaixada','gov','mil','net','org','principe','saotome','store'],
|
'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'],
|
'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'],
|
'sv' => ['com', 'edu', 'gob', 'org', 'red'],
|
||||||
'sy' => ['com','net','org','gov','edu','mil','name'],
|
'sy' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
|
||||||
'th' => ['ac','co','go','in','mi','net','or'],
|
'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'],
|
'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'],
|
'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'],
|
'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'],
|
'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'],
|
'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'],
|
'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'],
|
'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'],
|
'uy' => ['com', 'net', 'org', 'gub', 'mil', 'edu'],
|
||||||
've' => ['co','com','edu','gob','info','net','org','web'],
|
've' => ['co', 'com', 'edu', 'gob', 'info', 'net', 'org', 'web'],
|
||||||
'vn' => ['com','net','org','edu','gov','int','ac','biz','info','name','pro','health'],
|
'vn' => ['com', 'net', 'org', 'edu', 'gov', 'int', 'ac', 'biz', 'info', 'name', 'pro', 'health'],
|
||||||
'yu' => ['ac','co','edu','gov','org'],
|
'yu' => ['ac', 'co', 'edu', 'gov', 'org'],
|
||||||
'za' => ['ac','alt','co','edu','gov','law','mil','net','ngo','nom','org','school','tm','web'],
|
'za' => ['ac', 'alt', 'co', 'edu', 'gov', 'law', 'mil', 'net', 'ngo', 'nom', 'org', 'school', 'tm', 'web'],
|
||||||
];
|
];
|
||||||
|
|
||||||
private bool $subdomainRedirect;
|
private bool $subdomainRedirect;
|
||||||
@@ -111,38 +111,41 @@ final readonly class DomainManager implements DomainInterface
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
|
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
|
||||||
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
|
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
|
||||||
) {
|
) {
|
||||||
$this->subdomainRedirect = $subdomainRedirect;
|
$this->subdomainRedirect = $subdomainRedirect;
|
||||||
$this->authSubdomain = $authSubdomain;
|
$this->authSubdomain = $authSubdomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IE: "auth.example.com" or null if not using a separate subdomain
|
/** IE: "auth.example.com" or null if not using a separate subdomain.
|
||||||
* @return ?string Returns auth subdomain if configured, otherwise null */
|
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||||
public function getAuthSubdomain(): ?string
|
public function getAuthSubdomain(): ?string
|
||||||
{
|
{
|
||||||
if ($this->authBase()) {
|
if ($this->authBase()) {
|
||||||
return $this->authSubdomain;
|
return $this->authSubdomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** check if given url is an acceptable url for redirection
|
/** check if given url is an acceptable url for redirection.
|
||||||
* @param string $url Where we are thinking of sending the user
|
* @param string $url Where we are thinking of sending the user
|
||||||
|
*
|
||||||
* @return bool Returns true if it is acceptable to send the user there */
|
* @return bool Returns true if it is acceptable to send the user there */
|
||||||
public function validReturn(string $url): bool
|
public function validReturn(string $url): bool
|
||||||
{
|
{
|
||||||
/* ensure url is valid and, when using an auth subdomain,
|
/* ensure url is valid and, when using an auth subdomain,
|
||||||
* that the url host matches the base domain */
|
* that the url host matches the base domain */
|
||||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
if (!filter_var($url, \FILTER_VALIDATE_URL)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->authBase()) {
|
if ($this->authBase()) {
|
||||||
$host = parse_url($url, PHP_URL_HOST);
|
$host = parse_url($url, \PHP_URL_HOST);
|
||||||
if ($host === null || $host === false || $host === '') {
|
if (null === $host || false === $host || '' === $host) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* do not send the user to another domain */
|
/* do not send the user to another domain */
|
||||||
return $this->matchesAuth($host);
|
return $this->matchesAuth($host);
|
||||||
}
|
}
|
||||||
@@ -150,58 +153,64 @@ final readonly class DomainManager implements DomainInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** check if host-base matches auth-base
|
/** check if host-base matches auth-base.
|
||||||
* @param string $host
|
|
||||||
* @return bool returns true if and only if host matches base domain of auth */
|
* @return bool returns true if and only if host matches base domain of auth */
|
||||||
public function matchesAuth(string $host): bool
|
public function matchesAuth(string $host): bool
|
||||||
{
|
{
|
||||||
$hostBase = $this->baseDomain($host);
|
$hostBase = $this->baseDomain($host);
|
||||||
$authBase = $this->baseDomain($this->authSubdomain);
|
$authBase = $this->baseDomain($this->authSubdomain);
|
||||||
return $this->subdomainRedirect && $this->authSubdomain &&
|
|
||||||
$authBase && $authBase === $hostBase;
|
return $this->subdomainRedirect && $this->authSubdomain
|
||||||
|
&& $authBase && $authBase === $hostBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IE: "example.com" if central auth is something like "auth.example.com"
|
/** IE: "example.com" if central auth is something like "auth.example.com".
|
||||||
* @return string|null returns base domain if we are doing central auth */
|
* @return string|null returns base domain if we are doing central auth */
|
||||||
public function authBase(): ?string
|
public function authBase(): ?string
|
||||||
{
|
{
|
||||||
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
|
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
|
||||||
return $this->baseDomain($this->authSubdomain);
|
return $this->baseDomain($this->authSubdomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** this lets us determine the base domain of the given ip, localhost, or domain
|
/** this lets us determine the base domain of the given ip, localhost, or domain
|
||||||
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
||||||
* things like "localhost" and "8.8.8.8" will return null
|
* things like "localhost" and "8.8.8.8" will return null.
|
||||||
|
*
|
||||||
* @param string $host ip, localhost, or domain with zero or more subdomains
|
* @param string $host ip, localhost, or domain with zero or more subdomains
|
||||||
|
*
|
||||||
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
|
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
|
||||||
private function baseDomain(string $host): ?string
|
private function baseDomain(string $host): ?string
|
||||||
{
|
{
|
||||||
/* if host is an ip address (or localhost), leave it as is */
|
/* if host is an ip address (or localhost), leave it as is */
|
||||||
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
|
if (filter_var($host, \FILTER_VALIDATE_IP) || 'localhost' === $host) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$parts = explode('.', strtolower($host));
|
$parts = explode('.', strtolower($host));
|
||||||
$keep = $this->baseLength($parts);
|
$keep = $this->baseLength($parts);
|
||||||
$parts = array_slice($parts, -$keep);
|
$parts = \array_slice($parts, -$keep);
|
||||||
|
|
||||||
return implode('.', $parts);
|
return implode('.', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
|
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"].
|
||||||
* @param string[] $parts pieces of a domain split by "." dot
|
* @param string[] $parts pieces of a domain split by "." dot
|
||||||
|
*
|
||||||
* @return int typically 2 but sometimes 3 */
|
* @return int typically 2 but sometimes 3 */
|
||||||
private function baseLength(array $parts): int
|
private function baseLength(array $parts): int
|
||||||
{
|
{
|
||||||
$length = count($parts);
|
$length = \count($parts);
|
||||||
$baseLength = min(2, $length);
|
$baseLength = min(2, $length);
|
||||||
/* check if host should retain 3 parts, due to TLD */
|
/* check if host should retain 3 parts, due to TLD */
|
||||||
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
|
if (\count($parts) > 2 && isset(self::TLD[$parts[$length - 1]])
|
||||||
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
|
&& \in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
|
||||||
) {
|
) {
|
||||||
$baseLength = min(3, $length);
|
$baseLength = min(3, $length);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $baseLength;
|
return $baseLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-102
@@ -4,143 +4,124 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\AppConstants;
|
||||||
use App\Data\Payload;
|
use App\Data\Payload;
|
||||||
use App\Enum\Scope;
|
use App\Enum\Scope;
|
||||||
use App\MonitorCacheKeys;
|
|
||||||
use App\Trait\CookieNameTrait;
|
|
||||||
use App\Trait\GetTotpTrait;
|
use App\Trait\GetTotpTrait;
|
||||||
use App\Trait\MakeNonceTrait;
|
use App\Trait\MakeNonceTrait;
|
||||||
use App\Trait\StringTrait;
|
use App\Trait\StringTrait;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Override;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Throwable;
|
||||||
use Symfony\Component\Uid\Ulid;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a TOTP code (or backup code) and, on success, either grants
|
||||||
|
* access or starts a passkey registration.
|
||||||
|
*
|
||||||
|
* The "grant access" half lives in {@see SessionIssuer} so the passkey ceremony
|
||||||
|
* produces an identical response. This class keeps the part genuinely specific
|
||||||
|
* to code-based login: verifying the code and enforcing the single-use nonce.
|
||||||
|
*
|
||||||
|
* **Why the registration hand-off lives here.** Ticking "register this device"
|
||||||
|
* turns the form submission into a registration ceremony, and the TOTP check is
|
||||||
|
* what authorises it. That check — and the nonce check — already happen here, so
|
||||||
|
* a ceremony started anywhere earlier would mean validating the nonce somewhere
|
||||||
|
* new and risking spending it twice.
|
||||||
|
*/
|
||||||
final readonly class LoginManager implements LoginInterface
|
final readonly class LoginManager implements LoginInterface
|
||||||
{
|
{
|
||||||
use CookieNameTrait;
|
|
||||||
use GetTotpTrait;
|
use GetTotpTrait;
|
||||||
use MakeNonceTrait;
|
use MakeNonceTrait;
|
||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
|
||||||
private CacheItemPoolInterface $sessionCache;
|
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
CacheItemPoolInterface $sessionCache,
|
private BackupCodeInterface $backupCodeManager,
|
||||||
private BackupCodeInterface $backupCodeManager,
|
private SessionIssuerInterface $sessionIssuer,
|
||||||
private DomainInterface $domainManager,
|
private PasskeyInterface $passkeys,
|
||||||
) {
|
) {
|
||||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/**
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
public function checkToken(Payload $payload, Request $request): ?Response
|
public function checkToken(Payload $payload, Request $request): ?Response
|
||||||
{
|
{
|
||||||
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||||
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
|
if (Scope::Ip === $payload->scope && !$this->config->ipTtl()) {
|
||||||
/* requested to grant ip access, but that is not enabled */
|
/* requested to grant ip access, but that is not enabled */
|
||||||
$payload->scope = Scope::Cookie;
|
$payload->scope = Scope::Cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->getTotp()->verify($payload->token, null, 1) ||
|
if (!$this->getTotp()->verify($payload->token, null, 1)
|
||||||
$this->backupCodeManager->verifyAndConsume($payload->token)
|
&& !$this->backupCodeManager->verifyAndConsume($payload->token)
|
||||||
) {
|
) {
|
||||||
/* token is correct (TOTP or Backup) */
|
return null;
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/* token is correct (TOTP or Backup) */
|
||||||
private function setCookie(string $id, string $host): Cookie
|
|
||||||
{
|
/* if server nonce is found and is valid */
|
||||||
/* successful auth with token, store session and set the cookie */
|
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
|
||||||
$ulid = new Ulid();
|
if (!$nonceItem->isHit() || !$nonceItem->get()) {
|
||||||
$sessionCookie = $this->sessionCache->getItem(
|
return null;
|
||||||
$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(
|
/* mark nonce as spent */
|
||||||
name: $this->sessionCookieName($this->domainManager),
|
$nonceItem->set(false); /* invalid */
|
||||||
value: $ulid->toString(),
|
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
|
||||||
expire: time() + $this->config->cookieTtl(),
|
$this->nonceCache->save($nonceItem);
|
||||||
path: '/',
|
|
||||||
domain: $this->sessionCookieDomain($this->domainManager, $host),
|
/* the code and the nonce are both good from here on */
|
||||||
secure: true,
|
|
||||||
httpOnly: true,
|
if ($payload->register && $payload->json) {
|
||||||
sameSite: Cookie::SAMESITE_STRICT,
|
return $this->startRegistration($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sessionIssuer->issue(
|
||||||
|
$payload->id,
|
||||||
|
$payload->scope,
|
||||||
|
$request,
|
||||||
|
$payload->json,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/**
|
||||||
private function setIp(string $id, string $ip): void
|
* The registration hand-off: authorisation is already proven, so this issues
|
||||||
|
* the ceremony options back to the page instead of a session.
|
||||||
|
*
|
||||||
|
* `SessionIssuer` is deliberately not involved — the session is granted only
|
||||||
|
* once the new credential has actually been verified, at `register-finish`.
|
||||||
|
*
|
||||||
|
* **JSON only.** The checkbox is submitted through the same `X-Preauth` AJAX
|
||||||
|
* path as an ordinary login, so a failed attempt comes back as JSON carrying
|
||||||
|
* a fresh nonce. On the plain form-post path it would be HTML, the script's
|
||||||
|
* `response.json()` would throw, and — worse — the fresh nonce would be lost,
|
||||||
|
* so the user's retry would fail against a nonce that had already been spent.
|
||||||
|
* A non-JSON submission is therefore treated as an ordinary login; WebAuthn
|
||||||
|
* needs scripting regardless, so it is the checkbox that is the enhancement
|
||||||
|
* here, not the underlying login.
|
||||||
|
*/
|
||||||
|
private function startRegistration(Payload $payload): ?Response
|
||||||
{
|
{
|
||||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
try {
|
||||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
$payloadOut = $this->passkeys->beginRegistration($payload->id);
|
||||||
|
} catch (Throwable) {
|
||||||
|
/* a ceremony that cannot start must not become a 500 on the login
|
||||||
|
* page; falling through to the caller's failure path is the same
|
||||||
|
* treatment a wrong code gets */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$sessionIp = $this->sessionCache->getItem($ipKey);
|
$response = new Response(
|
||||||
$sessionIp->set($id);
|
(string) json_encode(['register' => $payloadOut]),
|
||||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
Response::HTTP_OK,
|
||||||
$this->sessionCache->save($sessionIp);
|
['Content-Type' => 'application/json'],
|
||||||
|
);
|
||||||
|
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,312 @@
|
|||||||
|
<?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.
|
||||||
|
*
|
||||||
|
* Inside the try: a cache failure must degrade to "this passkey is
|
||||||
|
* unavailable", never to a 500 on the login page. */
|
||||||
|
try {
|
||||||
|
$stored = $this->credentials->find($publicKeyCredential->rawId);
|
||||||
|
if (null === $stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->credentials->save($credential);
|
||||||
|
} catch (Throwable) {
|
||||||
|
/* a store that cannot persist a credential must not report success:
|
||||||
|
* the user would believe the passkey was registered and then find it
|
||||||
|
* missing at the next login */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -32,23 +32,23 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
|||||||
|
|
||||||
public function isEmpty(): bool
|
public function isEmpty(): bool
|
||||||
{
|
{
|
||||||
return $this->patterns === [];
|
return [] === $this->patterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function matches(string $host, string $path): bool
|
public function matches(string $host, string $path): bool
|
||||||
{
|
{
|
||||||
if ($this->patterns === []) {
|
if ([] === $this->patterns) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = strtolower($host);
|
$host = strtolower($host);
|
||||||
|
|
||||||
foreach ($this->patterns as $entry) {
|
foreach ($this->patterns as $entry) {
|
||||||
if ($entry['host'] !== null && $entry['host'] !== $host) {
|
if (null !== $entry['host'] && $entry['host'] !== $host) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match($entry['regex'], $path) === 1) {
|
if (1 === preg_match($entry['regex'], $path)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
|||||||
*/
|
*/
|
||||||
private function parse(string $publicPaths): array
|
private function parse(string $publicPaths): array
|
||||||
{
|
{
|
||||||
if (trim($publicPaths) === '') {
|
if ('' === trim($publicPaths)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
|||||||
|
|
||||||
foreach (explode(',', $publicPaths) as $raw) {
|
foreach (explode(',', $publicPaths) as $raw) {
|
||||||
$entry = trim($raw);
|
$entry = trim($raw);
|
||||||
if ($entry === '') {
|
if ('' === $entry) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$patterns[] = [
|
$patterns[] = [
|
||||||
'host' => $host,
|
'host' => $host,
|
||||||
'regex' => $this->compilePattern($path),
|
'regex' => $this->compilePattern($path),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -109,33 +109,33 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
|||||||
private function compilePattern(string $pattern): string
|
private function compilePattern(string $pattern): string
|
||||||
{
|
{
|
||||||
$regex = '';
|
$regex = '';
|
||||||
$length = strlen($pattern);
|
$length = \strlen($pattern);
|
||||||
$i = 0;
|
$i = 0;
|
||||||
|
|
||||||
while ($i < $length) {
|
while ($i < $length) {
|
||||||
// Check for ** (must be at current position)
|
// Check for ** (must be at current position)
|
||||||
if ($i + 1 < $length && $pattern[$i] === '*' && $pattern[$i + 1] === '*') {
|
if ($i + 1 < $length && '*' === $pattern[$i] && '*' === $pattern[$i + 1]) {
|
||||||
$i += 2;
|
$i += 2;
|
||||||
if ($i >= $length) {
|
if ($i >= $length) {
|
||||||
// ** at end of pattern: zero or more chars including /
|
// ** at end of pattern: zero or more chars including /
|
||||||
$regex .= '.*';
|
$regex .= '.*';
|
||||||
} elseif ($pattern[$i] === '/') {
|
} elseif ('/' === $pattern[$i]) {
|
||||||
// /**/ in middle: zero or more intermediate segments
|
// /**/ in middle: zero or more intermediate segments
|
||||||
$regex .= '(?:.*/)?';
|
$regex .= '(?:.*/)?';
|
||||||
$i += 1; // skip the / after **
|
++$i; // skip the / after **
|
||||||
} else {
|
} else {
|
||||||
// ** not followed by / or end, treat as .*
|
// ** not followed by / or end, treat as .*
|
||||||
$regex .= '.*';
|
$regex .= '.*';
|
||||||
}
|
}
|
||||||
} elseif ($pattern[$i] === '*') {
|
} elseif ('*' === $pattern[$i]) {
|
||||||
$regex .= '[^/]+';
|
$regex .= '[^/]+';
|
||||||
$i += 1;
|
++$i;
|
||||||
} else {
|
} else {
|
||||||
$regex .= preg_quote($pattern[$i], '#');
|
$regex .= preg_quote($pattern[$i], '#');
|
||||||
$i += 1;
|
++$i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '#^' . $regex . '$#';
|
return '#^'.$regex.'$#';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
$otp = Factory::loadFromProvisioningUri(
|
||||||
$this->config->totpUri(),
|
$this->config->totpUri(),
|
||||||
$this->config->clock()
|
$this->config->clock(),
|
||||||
);
|
);
|
||||||
if ($otp instanceof TOTPInterface) {
|
if ($otp instanceof TOTPInterface) {
|
||||||
return $otp;
|
return $otp;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Trait;
|
|||||||
use Exception;
|
use Exception;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Symfony\Contracts\Service\Attribute\Required;
|
use Symfony\Contracts\Service\Attribute\Required;
|
||||||
@@ -23,8 +24,9 @@ trait MakeNonceTrait
|
|||||||
protected readonly CacheItemPoolInterface $nonceCache;
|
protected readonly CacheItemPoolInterface $nonceCache;
|
||||||
|
|
||||||
#[Required]
|
#[Required]
|
||||||
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
|
public function setNonceCache(
|
||||||
{
|
#[Target('nonceCache')] CacheItemPoolInterface $nonceCache,
|
||||||
|
): void {
|
||||||
$this->nonceCache = $nonceCache;
|
$this->nonceCache = $nonceCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,18 +35,16 @@ trait MakeNonceTrait
|
|||||||
{
|
{
|
||||||
/* convert raw binary into base64url */
|
/* convert raw binary into base64url */
|
||||||
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
||||||
static::NONCE_LENGTH
|
static::NONCE_LENGTH,
|
||||||
)), '+/', '-_'), '=');
|
)), '+/', '-_'), '=');
|
||||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
|
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
|
||||||
|
|
||||||
if ($nonceItem->isHit()) {
|
if ($nonceItem->isHit()) {
|
||||||
if ($retries < 1) {
|
if ($retries < 1) {
|
||||||
$this->logger->error("aborting: multiple nonce collisions");
|
$this->logger->error('aborting: multiple nonce collisions');
|
||||||
throw new HttpException(
|
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||||
Response::HTTP_INTERNAL_SERVER_ERROR,
|
|
||||||
'Internal Server Error'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* managed to have a collision, try again */
|
/* managed to have a collision, try again */
|
||||||
return $this->makeNonce($retries - 1);
|
return $this->makeNonce($retries - 1);
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,7 @@ trait MakeNonceTrait
|
|||||||
$nonceItem->expiresAfter(static::NONCE_TTL);
|
$nonceItem->expiresAfter(static::NONCE_TTL);
|
||||||
$this->logger->debug("added nonce: $nonce");
|
$this->logger->debug("added nonce: $nonce");
|
||||||
$this->nonceCache->save($nonceItem);
|
$this->nonceCache->save($nonceItem);
|
||||||
|
|
||||||
return $nonce;
|
return $nonce;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ trait StringTrait
|
|||||||
$headers = ['Content-Type' => 'text/plain'];
|
$headers = ['Content-Type' => 'text/plain'];
|
||||||
|
|
||||||
$headerValue = $this->resolveRemoteUser($id, $config);
|
$headerValue = $this->resolveRemoteUser($id, $config);
|
||||||
if ($headerValue !== null) {
|
if (null !== $headerValue) {
|
||||||
$headers['Remote-User'] = $headerValue;
|
$headers['Remote-User'] = $headerValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +45,9 @@ trait StringTrait
|
|||||||
{
|
{
|
||||||
return match ($config->remoteUserMode()) {
|
return match ($config->remoteUserMode()) {
|
||||||
RemoteUserMode::Session => $id,
|
RemoteUserMode::Session => $id,
|
||||||
RemoteUserMode::Static => $config->remoteUserStatic(),
|
RemoteUserMode::Static => $config->remoteUserStatic(),
|
||||||
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
|
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
|
||||||
RemoteUserMode::None => null,
|
RemoteUserMode::None => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -15,7 +15,7 @@ use Psr\Clock\ClockInterface;
|
|||||||
final readonly class Utilities
|
final readonly class Utilities
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ClockInterface $clock,
|
private ClockInterface $clock,
|
||||||
private CacheItemPoolInterface $appPool,
|
private CacheItemPoolInterface $appPool,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,7 @@ final readonly class Utilities
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->showTotp($totp);
|
$this->showTotp($totp);
|
||||||
|
|
||||||
return $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 */
|
* we want this to keep forever, so a few hundred years should do it */
|
||||||
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d',
|
'Y-m-d',
|
||||||
AppConstants::FAR_FUTURE_DATE
|
AppConstants::FAR_FUTURE_DATE,
|
||||||
));
|
));
|
||||||
$this->appPool->save($totpItem);
|
$this->appPool->save($totpItem);
|
||||||
|
|
||||||
return $totp;
|
return $totp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ final readonly class Utilities
|
|||||||
loading TOTP, because the env is not set, please copy above into TOTP_URI
|
loading TOTP, because the env is not set, please copy above into TOTP_URI
|
||||||
|
|
||||||
RAW,
|
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": {
|
"friendsofphp/php-cs-fixer": {
|
||||||
"version": "3.95",
|
"version": "3.95",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -11,6 +20,9 @@
|
|||||||
".php-cs-fixer.dist.php"
|
".php-cs-fixer.dist.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"phpstan/phpstan": {
|
||||||
|
"version": "2.2.15"
|
||||||
|
},
|
||||||
"phpunit/phpunit": {
|
"phpunit/phpunit": {
|
||||||
"version": "13.2",
|
"version": "13.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -71,6 +83,18 @@
|
|||||||
".editorconfig"
|
".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": {
|
"symfony/routing": {
|
||||||
"version": "7.4",
|
"version": "7.4",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
{#
|
||||||
|
Passkey UI. Only included when passkeys are available, so that an
|
||||||
|
unavailable configuration renders a byte-identical login page.
|
||||||
|
|
||||||
|
Every string that reaches the server is base64url with no padding, matching
|
||||||
|
what webauthn-lib expects — the library rejects anything else, and the
|
||||||
|
failure mode ("invalid signature") looks nothing like an encoding bug.
|
||||||
|
#}
|
||||||
|
<div class="center passkey-row">
|
||||||
|
<button type="button" id="preauth-passkey">{{ env.passkey_button_name }}</button>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
div.passkey-row { width: 100%; }
|
||||||
|
div.passkey-row button { background-color: #ffffff; }
|
||||||
|
label.passkey-label { display: inline-block; text-align: left; }
|
||||||
|
label.passkey-label input { width: auto; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById('preauth-form');
|
||||||
|
const message = document.getElementById('preauth-message');
|
||||||
|
const button = document.getElementById('preauth-passkey');
|
||||||
|
const checkbox = document.getElementById('preauth-register');
|
||||||
|
|
||||||
|
/* base64url <-> ArrayBuffer, exactly as webauthn-lib encodes these fields */
|
||||||
|
const b64url = {
|
||||||
|
encode: (value) => btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
||||||
|
decode: (value) => {
|
||||||
|
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
|
||||||
|
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = (text) => { if (message) { message.innerText = text; } };
|
||||||
|
|
||||||
|
/* POST a ceremony step and return the parsed JSON body */
|
||||||
|
const ceremony = (operation, body) => fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Preauth-Passkey': operation,
|
||||||
|
},
|
||||||
|
// never serve this request from, or store it in, the HTTP cache
|
||||||
|
cache: 'no-store',
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
}).then((response) => response.json().then((content) => ({ response, content })));
|
||||||
|
|
||||||
|
const descriptors = (list) => (list ?? []).map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
id: b64url.decode(entry.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/* ── login (assertion) ────────────────────────────────────────────── */
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
ceremony('login-begin')
|
||||||
|
.then(({ content }) => navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
...content.publicKey,
|
||||||
|
challenge: b64url.decode(content.publicKey.challenge),
|
||||||
|
allowCredentials: descriptors(content.publicKey.allowCredentials),
|
||||||
|
},
|
||||||
|
}).then((assertion) => ceremony('login-finish', {
|
||||||
|
ceremonyId: content.ceremonyId,
|
||||||
|
credential: {
|
||||||
|
id: assertion.id,
|
||||||
|
rawId: b64url.encode(assertion.rawId),
|
||||||
|
type: assertion.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: b64url.encode(assertion.response.clientDataJSON),
|
||||||
|
authenticatorData: b64url.encode(assertion.response.authenticatorData),
|
||||||
|
signature: b64url.encode(assertion.response.signature),
|
||||||
|
userHandle: assertion.response.userHandle
|
||||||
|
? b64url.encode(assertion.response.userHandle) : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
.then(({ response, content }) => {
|
||||||
|
if (response.headers.has('Location')) {
|
||||||
|
window.location.replace(response.headers.get('Location'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show(content.message ?? '');
|
||||||
|
if (Object.hasOwn(content, 'nonce') && form.nonce) {
|
||||||
|
form.nonce.value = content.nonce;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log('passkey login failed');
|
||||||
|
console.log(error);
|
||||||
|
show({{ env.error_message|json_encode|raw }});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── registration ─────────────────────────────────────────────────── */
|
||||||
|
/* The checkbox turns an ordinary submit into a registration ceremony.
|
||||||
|
Authorisation is the TOTP check the server performs on that same
|
||||||
|
submission, so a ceremony cannot be started without a valid code.
|
||||||
|
|
||||||
|
This goes through the same X-Preauth AJAX path as a normal login rather
|
||||||
|
than a plain form post, so that failures come back as JSON with a fresh
|
||||||
|
nonce — a form post would return HTML and lose the nonce, making the
|
||||||
|
user's next attempt fail for a reason they could not see. */
|
||||||
|
if (form && checkbox) {
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (!checkbox.checked) {
|
||||||
|
/* not registering: leave the normal submit path alone */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const data = btoa(JSON.stringify({
|
||||||
|
id: form.username.value?.trim() ?? '',
|
||||||
|
token: form.totp.value?.trim() ?? '',
|
||||||
|
nonce: form.nonce.value?.trim() ?? '',
|
||||||
|
register: 'passkey',
|
||||||
|
json: true,
|
||||||
|
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'X-Preauth': data },
|
||||||
|
cache: 'no-store',
|
||||||
|
}).then((response) => response.json()).then((content) => {
|
||||||
|
if (!content.register) {
|
||||||
|
show(content.message ?? '');
|
||||||
|
if (Object.hasOwn(content, 'nonce')) {
|
||||||
|
form.nonce.value = content.nonce;
|
||||||
|
form.totp.value = '';
|
||||||
|
form.totp.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = content.register.publicKey;
|
||||||
|
return navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
...options,
|
||||||
|
challenge: b64url.decode(options.challenge),
|
||||||
|
user: { ...options.user, id: b64url.decode(options.user.id) },
|
||||||
|
excludeCredentials: descriptors(options.excludeCredentials),
|
||||||
|
},
|
||||||
|
}).then((attestation) => ceremony('register-finish', {
|
||||||
|
ceremonyId: content.register.ceremonyId,
|
||||||
|
credential: {
|
||||||
|
id: attestation.id,
|
||||||
|
rawId: b64url.encode(attestation.rawId),
|
||||||
|
type: attestation.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: b64url.encode(attestation.response.clientDataJSON),
|
||||||
|
attestationObject: b64url.encode(attestation.response.attestationObject),
|
||||||
|
transports: attestation.response.getTransports
|
||||||
|
? attestation.response.getTransports() : [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})).then(({ response, content: finished }) => {
|
||||||
|
if (response.headers.has('Location')) {
|
||||||
|
window.location.replace(response.headers.get('Location'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show(finished.message ?? '');
|
||||||
|
});
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log('passkey registration failed');
|
||||||
|
console.log(error);
|
||||||
|
show({{ env.error_message|json_encode|raw }});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -19,6 +19,8 @@ form.addEventListener('submit', (event) => {
|
|||||||
fetch(window.location.href, {
|
fetch(window.location.href, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'X-Preauth': data },
|
headers: { 'X-Preauth': data },
|
||||||
|
// never serve this request from, or store it in, the HTTP cache
|
||||||
|
cache: 'no-store',
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
{% if env.debug > 2 -%}
|
{% if env.debug > 2 -%}
|
||||||
console.log(response);
|
console.log(response);
|
||||||
@@ -28,7 +30,8 @@ form.addEventListener('submit', (event) => {
|
|||||||
{% if env.debug > 2 -%}
|
{% if env.debug > 2 -%}
|
||||||
console.log('got redirect response');
|
console.log('got redirect response');
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
window.location.href = response.headers.get('Location');
|
// replace() keeps the login page out of history and the back-forward cache
|
||||||
|
window.location.replace(response.headers.get('Location'));
|
||||||
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
|
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
|
||||||
{# got json, update the page #}
|
{# got json, update the page #}
|
||||||
{% if env.debug > 2 -%}
|
{% if env.debug > 2 -%}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{{ env.title }}</title>
|
<title>{{ env.title }}</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
{{- include('_style.html.twig') -}}
|
{{- include('_style.html.twig') -}}
|
||||||
</head>
|
</head>
|
||||||
<body id="preauth-body">
|
<body id="preauth-body">
|
||||||
|
|||||||
@@ -14,9 +14,19 @@
|
|||||||
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
|
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
|
||||||
<div><input type="text" name="totp" id="totp"
|
<div><input type="text" name="totp" id="totp"
|
||||||
autocomplete="one-time-code" required="required"></div>
|
autocomplete="one-time-code" required="required"></div>
|
||||||
|
{% if (passkeys ?? false) and (post ?? false) %}
|
||||||
|
{# Only where the form actually POSTs: registration authorises itself with
|
||||||
|
the TOTP code carried in that submission, so a fetch()-submitted form on
|
||||||
|
a protected host has nothing to start a ceremony with. #}
|
||||||
|
<div class="center passkey-row"><label class="passkey-label" for="preauth-register">
|
||||||
|
<input type="checkbox" name="register" id="preauth-register" value="passkey"> {{ env.passkey_register_name }}</label></div>
|
||||||
|
{% endif %}
|
||||||
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
|
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
|
||||||
</form>
|
</form>
|
||||||
{% if not post ?? false %}
|
{% if not post ?? false %}
|
||||||
{{- include('_script.html.twig') -}}
|
{{- include('_script.html.twig') -}}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if passkeys ?? false %}
|
||||||
|
{{- include('_passkey.html.twig') -}}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
|
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
|
||||||
private function encodePayload(array $data): string
|
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), '+/', '-_'), '=');
|
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,16 +54,16 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
bool $json = true,
|
bool $json = true,
|
||||||
): string {
|
): string {
|
||||||
return $this->encodePayload([
|
return $this->encodePayload([
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'token' => $token ?? $this->validTotpCode(),
|
'token' => $token ?? $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => $json,
|
'json' => $json,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── unauthenticated access ──────────────────────────────────────── */
|
/* ── unauthenticated access ──────────────────────────────────────── */
|
||||||
|
|
||||||
public function testUnauthenticatedRequestShowsLoginPage(): void
|
public function test_unauthenticated_request_shows_login_page(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/');
|
$client->request('GET', '/');
|
||||||
@@ -75,7 +76,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertSelectorExists('input[name="totp"]');
|
self::assertSelectorExists('input[name="totp"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoginPageContainsGeneratedNonce(): void
|
public function test_login_page_contains_generated_nonce(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$crawler = $client->request('GET', '/');
|
$crawler = $client->request('GET', '/');
|
||||||
@@ -86,7 +87,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
|
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();
|
$client = static::createClient();
|
||||||
$crawler = $client->request('GET', '/');
|
$crawler = $client->request('GET', '/');
|
||||||
@@ -99,7 +100,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── successful TOTP login ────────────────────────────────────────── */
|
/* ── successful TOTP login ────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
|
public function test_successful_totp_login_via_header_sets_cookie_and_redirects(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -111,10 +112,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
// now submit a valid TOTP via the X-Preauth header
|
// now submit a valid TOTP via the X-Preauth header
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'alice',
|
'id' => 'alice',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'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');
|
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();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -141,10 +142,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'bob',
|
'id' => 'bob',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertSame('Login successful', $body['message']);
|
self::assertSame('Login successful', $body['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
|
public function test_successful_login_returns_html_when_json_false(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -164,10 +165,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'carol',
|
'id' => 'carol',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => false,
|
'json' => false,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
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();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -186,10 +187,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'dave',
|
'id' => 'dave',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertSame('dave', $response->headers->get('Remote-User'));
|
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();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -223,7 +224,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'eve',
|
'id' => 'eve',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'scope' => 'none',
|
'scope' => 'none',
|
||||||
@@ -240,7 +241,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── failed login ─────────────────────────────────────────────────── */
|
/* ── failed login ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
|
public function test_failed_login_returns_unauthorized_json_with_error(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -249,10 +250,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'alice',
|
'id' => 'alice',
|
||||||
'token' => '000000', // wrong code
|
'token' => '000000', // wrong code
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -266,7 +267,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertNotEmpty($body['nonce']);
|
self::assertNotEmpty($body['nonce']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
|
public function test_failed_login_returns_html_when_json_false(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -275,10 +276,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'alice',
|
'id' => 'alice',
|
||||||
'token' => 'wrong-code',
|
'token' => 'wrong-code',
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => false,
|
'json' => false,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -288,7 +289,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertSelectorExists('form#preauth-form');
|
self::assertSelectorExists('form#preauth-form');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFailedLoginWithSpentNonceIsRejected(): void
|
public function test_failed_login_with_spent_nonce_is_rejected(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -298,10 +299,10 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
// first: successful login consumes the nonce
|
// first: successful login consumes the nonce
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'alice',
|
'id' => 'alice',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
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
|
// reuse the same nonce — should fail even with a valid token
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'alice',
|
'id' => 'alice',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFailedLoginWithInvalidNonceIsRejected(): void
|
public function test_failed_login_with_invalid_nonce_is_rejected(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
// skip fetching a real nonce; use one that was never stored
|
// skip fetching a real nonce; use one that was never stored
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'alice',
|
'id' => 'alice',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => 'never-issued-nonce',
|
'nonce' => 'never-issued-nonce',
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -342,7 +343,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── invalid payload ──────────────────────────────────────────────── */
|
/* ── invalid payload ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
|
public function test_invalid_header_payload_returns_unauthorized(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -354,7 +355,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
|
public function test_payload_with_missing_fields_returns_unauthorized(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -370,7 +371,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── invalid cookie ───────────────────────────────────────────────── */
|
/* ── invalid cookie ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testInvalidCookieIsClearedAndLoginPageShown(): void
|
public function test_invalid_cookie_is_cleared_and_login_page_shown(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -388,7 +389,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
'Strict',
|
'Strict',
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
$client->request('GET', 'https://localhost/');
|
$client->request('GET', 'https://localhost/');
|
||||||
@@ -399,7 +400,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
// the stale cookie should be cleared
|
// the stale cookie should be cleared
|
||||||
$cleared = false;
|
$cleared = false;
|
||||||
foreach ($response->headers->getCookies() as $cookie) {
|
foreach ($response->headers->getCookies() as $cookie) {
|
||||||
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
|
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
|
||||||
$cleared = true;
|
$cleared = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,7 +409,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── backup code authentication ───────────────────────────────────── */
|
/* ── backup code authentication ───────────────────────────────────── */
|
||||||
|
|
||||||
public function testBackupCodeAuthenticationWorks(): void
|
public function test_backup_code_authentication_works(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$container = $client->getContainer();
|
$container = $client->getContainer();
|
||||||
@@ -423,17 +424,17 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/', [], [], [
|
$client->request('GET', '/', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'frank',
|
'id' => 'frank',
|
||||||
'token' => $codes[0],
|
'token' => $codes[0],
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testConsumedBackupCodeCannotBeReused(): void
|
public function test_consumed_backup_code_cannot_be_reused(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$container = $client->getContainer();
|
$container = $client->getContainer();
|
||||||
@@ -469,7 +470,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── return URL handling ──────────────────────────────────────────── */
|
/* ── return URL handling ──────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testSuccessfulLoginWithValidReturnUrl(): void
|
public function test_successful_login_with_valid_return_url(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
@@ -488,7 +489,7 @@ final class AuthenticationFlowTest extends WebTestCase
|
|||||||
self::assertSame('https://example.com/app', $response->headers->get('Location'));
|
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();
|
$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,614 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use App\AppConstants;
|
||||||
|
use App\Tests\Support\PasskeyTestHelper;
|
||||||
|
use OTPHP\TOTP;
|
||||||
|
use Override;
|
||||||
|
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole flow through the real HTTP kernel, with real cryptography.
|
||||||
|
*
|
||||||
|
* Nothing about the ceremony is stubbed: the registration builds a genuine CBOR
|
||||||
|
* attestation object signed by a real P-256 key, and the login signs a real
|
||||||
|
* assertion. So a pass here means the feature works, not that our mocks agree
|
||||||
|
* with our code. What *is* simulated is only the browser's plumbing — the
|
||||||
|
* `fetch()` calls become requests, which is exactly the seam worth testing.
|
||||||
|
*
|
||||||
|
* Passkeys are off in `.env.test` (most of the suite expects today's behaviour),
|
||||||
|
* so this test turns them on for itself.
|
||||||
|
*/
|
||||||
|
final class PasskeyFlowTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
|
private const string IDENTITY = 'lyra';
|
||||||
|
|
||||||
|
/** The auth subdomain, which is also the only allowed ceremony origin. */
|
||||||
|
private const string AUTH_HOST = 'auth.example.com';
|
||||||
|
|
||||||
|
/** The RP ID: the base domain, so credentials are shared across it. */
|
||||||
|
private const string RP_ID = 'example.com';
|
||||||
|
|
||||||
|
private const string ORIGIN = 'https://auth.example.com';
|
||||||
|
|
||||||
|
private const string AUTH_COOKIE = '__Http-Domain-Preauth';
|
||||||
|
|
||||||
|
private ?PasskeyTestHelper $helper = null;
|
||||||
|
|
||||||
|
/** One kernel per test, as WebTestCase requires. */
|
||||||
|
private ?KernelBrowser $client = null;
|
||||||
|
|
||||||
|
private ?string $credentialId = null;
|
||||||
|
|
||||||
|
/** @var array<string,string> */
|
||||||
|
private static array $passkeyEnv = [
|
||||||
|
'PASSKEY_ENABLED' => '1',
|
||||||
|
'SUBDOMAIN_REDIRECT' => '1',
|
||||||
|
'AUTH_SUBDOMAIN' => self::AUTH_HOST,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn passkeys on for this test only.
|
||||||
|
*
|
||||||
|
* Env placeholders resolve at runtime, so setting these before the kernel
|
||||||
|
* boots is enough and no separate cache directory is needed.
|
||||||
|
*/
|
||||||
|
private function createPasskeyClient(): KernelBrowser
|
||||||
|
{
|
||||||
|
foreach (self::$passkeyEnv as $name => $value) {
|
||||||
|
$_ENV[$name] = $value;
|
||||||
|
$_SERVER[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WebTestCase allows exactly one kernel per test, so a test needing a
|
||||||
|
* second "visitor" gets this browser with a cleared cookie jar rather
|
||||||
|
* than a new kernel. */
|
||||||
|
if (null === $this->client) {
|
||||||
|
$this->client = static::createClient();
|
||||||
|
$this->client->disableReboot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same kernel, with no cookies — a fresh visitor.
|
||||||
|
*
|
||||||
|
* Needed because a granted session makes AcceptListener short-circuit at 200
|
||||||
|
* before any ceremony listener runs, so a test that registers first and then
|
||||||
|
* wants to exercise a ceremony must not carry that cookie.
|
||||||
|
*/
|
||||||
|
private function freshVisitor(): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
$client->getCookieJar()->clear();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach (array_keys(self::$passkeyEnv) as $name) {
|
||||||
|
unset($_ENV[$name], $_SERVER[$name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function helper(): PasskeyTestHelper
|
||||||
|
{
|
||||||
|
return $this->helper ??= new PasskeyTestHelper();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function credentialId(): string
|
||||||
|
{
|
||||||
|
return $this->credentialId ??= $this->helper()->credentialId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validTotpCode(): string
|
||||||
|
{
|
||||||
|
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nonce issued with the login page, which the form must echo back.
|
||||||
|
*/
|
||||||
|
private function nonceFrom(KernelBrowser $client): string
|
||||||
|
{
|
||||||
|
$crawler = $client->request('GET', self::ORIGIN.'/');
|
||||||
|
|
||||||
|
return (string) $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1+2 of registration: submit the form with the checkbox ticked.
|
||||||
|
*
|
||||||
|
* @return array{publicKey: array<string,mixed>, ceremonyId: string}
|
||||||
|
*/
|
||||||
|
private function beginRegistration(KernelBrowser $client, string $nonce, string $totp = ''): array
|
||||||
|
{
|
||||||
|
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => self::IDENTITY,
|
||||||
|
'token' => '' === $totp ? $this->validTotpCode() : $totp,
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'register' => 'passkey',
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), (string) $response->getContent());
|
||||||
|
|
||||||
|
$content = json_decode((string) $response->getContent(), true);
|
||||||
|
self::assertIsArray($content);
|
||||||
|
self::assertArrayHasKey('register', $content);
|
||||||
|
|
||||||
|
return $content['register'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ordinary code login, returning the cookie it sets.
|
||||||
|
*
|
||||||
|
* Used to prove both login paths agree on the cookie; the passkey flow is
|
||||||
|
* otherwise easy to break in a way that only shows up in a browser.
|
||||||
|
*/
|
||||||
|
private function codeLoginCookie(): \Symfony\Component\HttpFoundation\Cookie
|
||||||
|
{
|
||||||
|
$client = $this->freshVisitor();
|
||||||
|
$nonce = $this->nonceFrom($client);
|
||||||
|
|
||||||
|
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => self::IDENTITY,
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
|
||||||
|
|
||||||
|
return $this->authCookieFrom($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base64url-encode a payload, matching the client-side script.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $data
|
||||||
|
*/
|
||||||
|
private function encodePayload(array $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode((string) json_encode($data)), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of registration: send the attestation the authenticator produced.
|
||||||
|
*/
|
||||||
|
private function finishRegistration(KernelBrowser $client, string $ceremonyId, string $challenge): Response
|
||||||
|
{
|
||||||
|
$credential = $this->helper()->registrationCredential(
|
||||||
|
self::RP_ID,
|
||||||
|
$challenge,
|
||||||
|
self::ORIGIN,
|
||||||
|
$this->credentialId(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->request(
|
||||||
|
'POST',
|
||||||
|
self::ORIGIN.'/',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'register-finish',
|
||||||
|
],
|
||||||
|
(string) json_encode(['ceremonyId' => $ceremonyId, 'credential' => $credential]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $client->getResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── registration ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The headline end-to-end property: a real registration is accepted and
|
||||||
|
* grants a session.
|
||||||
|
*/
|
||||||
|
public function test_a_real_registration_grants_a_session(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
$nonce = $this->nonceFrom($client);
|
||||||
|
|
||||||
|
$started = $this->beginRegistration($client, $nonce);
|
||||||
|
self::assertArrayHasKey('ceremonyId', $started);
|
||||||
|
self::assertSame(self::RP_ID, $started['publicKey']['rp']['id']);
|
||||||
|
|
||||||
|
$response = $this->finishRegistration(
|
||||||
|
$client,
|
||||||
|
$started['ceremonyId'],
|
||||||
|
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
|
||||||
|
|
||||||
|
/* the session cookie is domain-scoped so every subdomain accepts it */
|
||||||
|
$cookie = $this->authCookieFrom($response);
|
||||||
|
self::assertSame(self::AUTH_COOKIE, $cookie->getName());
|
||||||
|
self::assertSame(self::RP_ID, $cookie->getDomain());
|
||||||
|
self::assertTrue($cookie->isSecure());
|
||||||
|
self::assertTrue($cookie->isHttpOnly());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration is authorised by the TOTP code, so a bad code must not start
|
||||||
|
* a ceremony — and must not leave one behind to be finished later.
|
||||||
|
*/
|
||||||
|
public function test_registration_with_a_bad_code_starts_no_ceremony(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
$nonce = $this->nonceFrom($client);
|
||||||
|
|
||||||
|
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => self::IDENTITY,
|
||||||
|
'token' => '000000',
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'register' => 'passkey',
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
|
||||||
|
$content = json_decode((string) $response->getContent(), true);
|
||||||
|
self::assertIsArray($content);
|
||||||
|
self::assertArrayNotHasKey('register', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spent nonce must be refused even with a valid code, or the ceremony
|
||||||
|
* hand-off would be replayable.
|
||||||
|
*/
|
||||||
|
public function test_registration_with_a_spent_nonce_starts_no_ceremony(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
$nonce = $this->nonceFrom($client);
|
||||||
|
|
||||||
|
/* spend the nonce with a first successful login */
|
||||||
|
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => self::IDENTITY,
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(Response::HTTP_SEE_OTHER, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
/* now try to reuse it for registration */
|
||||||
|
$reuse = $this->freshVisitor();
|
||||||
|
$reuse->request('GET', self::ORIGIN.'/', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => self::IDENTITY,
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'register' => 'passkey',
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $reuse->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── login ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other half of the story: register once, then log in with the passkey
|
||||||
|
* instead of a code — through the real validator, with a real signature.
|
||||||
|
*/
|
||||||
|
public function test_a_real_passkey_login_grants_a_session(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
|
||||||
|
/* register first */
|
||||||
|
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||||
|
$registered = $this->finishRegistration(
|
||||||
|
$client,
|
||||||
|
$started['ceremonyId'],
|
||||||
|
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||||
|
);
|
||||||
|
self::assertSame(Response::HTTP_SEE_OTHER, $registered->getStatusCode());
|
||||||
|
|
||||||
|
/* the stored record's counter is the one the registration used, and the
|
||||||
|
* lenient policy accepts an equal or greater value */
|
||||||
|
$counter = $this->helper()->counter() + 1;
|
||||||
|
|
||||||
|
/* drop the cookie the registration granted, or AcceptListener would
|
||||||
|
* answer before the ceremony listener is reached */
|
||||||
|
$client = $this->freshVisitor();
|
||||||
|
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||||
|
], '{}');
|
||||||
|
|
||||||
|
$begin = json_decode((string) $client->getResponse()->getContent(), true);
|
||||||
|
self::assertIsArray($begin);
|
||||||
|
self::assertSame(self::RP_ID, $begin['publicKey']['rpId']);
|
||||||
|
|
||||||
|
/* the registered credential is offered to the authenticator */
|
||||||
|
self::assertNotEmpty($begin['publicKey']['allowCredentials']);
|
||||||
|
|
||||||
|
$challenge = Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']);
|
||||||
|
$assertion = $this->helper()->assertionCredential(
|
||||||
|
self::RP_ID,
|
||||||
|
$challenge,
|
||||||
|
self::ORIGIN,
|
||||||
|
$this->credentialId(),
|
||||||
|
$counter,
|
||||||
|
hash('sha256', self::IDENTITY, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-finish',
|
||||||
|
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
|
||||||
|
|
||||||
|
/* the Remote-User header proves which identity was authenticated */
|
||||||
|
self::assertSame(self::IDENTITY, $response->headers->get('Remote-User'));
|
||||||
|
self::assertSame(self::AUTH_COOKIE, $this->authCookieFrom($response)->getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A replayed ceremony must fail: the challenge is consumed on first use, so
|
||||||
|
* an observed `finish` cannot be re-sent.
|
||||||
|
*/
|
||||||
|
public function test_a_replayed_ceremony_fails(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
|
||||||
|
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||||
|
$challenge = Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']);
|
||||||
|
|
||||||
|
$first = $this->finishRegistration($client, $started['ceremonyId'], $challenge);
|
||||||
|
self::assertSame(Response::HTTP_SEE_OTHER, $first->getStatusCode());
|
||||||
|
|
||||||
|
/* a replay comes from someone who does not hold the session the first
|
||||||
|
* attempt just created, so the cookie must go — otherwise AcceptListener
|
||||||
|
* answers 200 and the ceremony listener never sees the replay */
|
||||||
|
$replay = $this->finishRegistration($this->freshVisitor(), $started['ceremonyId'], $challenge);
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $replay->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An assertion signed over a different challenge must be refused, which is
|
||||||
|
* what binds a login to this session rather than to any past one.
|
||||||
|
*/
|
||||||
|
public function test_an_assertion_for_another_challenge_fails(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
|
||||||
|
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||||
|
$registered = $this->finishRegistration(
|
||||||
|
$client,
|
||||||
|
$started['ceremonyId'],
|
||||||
|
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||||
|
);
|
||||||
|
/* the stored record's counter is the one the registration used, and the
|
||||||
|
* lenient policy accepts an equal or greater value */
|
||||||
|
$counter = $this->helper()->counter() + 1;
|
||||||
|
|
||||||
|
$client = $this->freshVisitor();
|
||||||
|
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||||
|
], '{}');
|
||||||
|
$begin = json_decode((string) $client->getResponse()->getContent(), true);
|
||||||
|
self::assertIsArray($begin);
|
||||||
|
|
||||||
|
/* sign a challenge the server never issued */
|
||||||
|
$assertion = $this->helper()->assertionCredential(
|
||||||
|
self::RP_ID,
|
||||||
|
random_bytes(32),
|
||||||
|
self::ORIGIN,
|
||||||
|
$this->credentialId(),
|
||||||
|
$counter,
|
||||||
|
hash('sha256', self::IDENTITY, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-finish',
|
||||||
|
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unknown credential must fail with the same generic message a wrong code
|
||||||
|
* gets, so the endpoint cannot be used to enumerate live credentials.
|
||||||
|
*/
|
||||||
|
public function test_an_unknown_credential_fails_generically(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
|
||||||
|
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||||
|
], '{}');
|
||||||
|
$begin = json_decode((string) $client->getResponse()->getContent(), true);
|
||||||
|
self::assertIsArray($begin);
|
||||||
|
|
||||||
|
/* a credential nobody registered, signed correctly against this challenge */
|
||||||
|
$assertion = $this->helper()->assertionCredential(
|
||||||
|
self::RP_ID,
|
||||||
|
Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']),
|
||||||
|
self::ORIGIN,
|
||||||
|
random_bytes(16),
|
||||||
|
1,
|
||||||
|
hash('sha256', 'nobody', true),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-finish',
|
||||||
|
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
|
||||||
|
/* and the wording matches the ordinary failure, with no hint that the
|
||||||
|
* credential was unknown */
|
||||||
|
$content = json_decode((string) $response->getContent(), true);
|
||||||
|
self::assertIsArray($content);
|
||||||
|
self::assertSame('Unsuccessful login attempt', $content['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the shared session (why SessionIssuer exists) ────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both login paths must produce the *same* cookie, or a user would appear
|
||||||
|
* logged in on the auth subdomain but not on the protected one.
|
||||||
|
*/
|
||||||
|
public function test_a_passkey_login_sets_the_same_cookie_as_a_code_login(): void
|
||||||
|
{
|
||||||
|
/* a code login, for comparison — via the same AJAX path the passkey
|
||||||
|
* script uses, so any difference is in the cookie and nothing else */
|
||||||
|
$codeCookie = $this->codeLoginCookie();
|
||||||
|
|
||||||
|
/* Now the passkey path, on a visitor with no session: the code login
|
||||||
|
* above set a cookie, and with it the login page is replaced by
|
||||||
|
* AcceptListener's "already authenticated" reply. */
|
||||||
|
$client = $this->freshVisitor();
|
||||||
|
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||||
|
$registered = $this->finishRegistration(
|
||||||
|
$client,
|
||||||
|
$started['ceremonyId'],
|
||||||
|
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||||
|
);
|
||||||
|
$passkeyCookie = $this->authCookieFrom($registered);
|
||||||
|
|
||||||
|
self::assertSame($codeCookie->getName(), $passkeyCookie->getName());
|
||||||
|
self::assertSame($codeCookie->getDomain(), $passkeyCookie->getDomain());
|
||||||
|
self::assertSame($codeCookie->getPath(), $passkeyCookie->getPath());
|
||||||
|
self::assertSame($codeCookie->isSecure(), $passkeyCookie->isSecure());
|
||||||
|
self::assertSame($codeCookie->isHttpOnly(), $passkeyCookie->isHttpOnly());
|
||||||
|
self::assertSame($codeCookie->getSameSite(), $passkeyCookie->getSameSite());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── caching and availability ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ceremony reply is a browser-facing 2xx, which nothing else in this app
|
||||||
|
* produces, so it must carry the full no-store set or a browser could
|
||||||
|
* replay a stale challenge.
|
||||||
|
*/
|
||||||
|
public function test_ceremony_responses_are_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
|
||||||
|
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||||
|
], '{}');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
|
||||||
|
|
||||||
|
/* the internal marker must not leak to the browser */
|
||||||
|
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With the feature off the listener must be inert, so a caller cannot even
|
||||||
|
* obtain a challenge. This uses the default test environment, where
|
||||||
|
* PASSKEY_ENABLED is 0.
|
||||||
|
*/
|
||||||
|
public function test_the_ceremony_is_inert_when_passkeys_are_disabled(): void
|
||||||
|
{
|
||||||
|
/* no passkey env set, so PASSKEY_ENABLED keeps its .env.test value of 0 */
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('POST', 'https://'.self::AUTH_HOST.'/', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||||
|
], '{}');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||||
|
|
||||||
|
/* however the listener answered, the caller must not have been given a
|
||||||
|
* challenge — that is the property under test */
|
||||||
|
self::assertStringNotContainsString('publicKey', (string) $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The login page must not offer what the server refuses, and vice versa.
|
||||||
|
*/
|
||||||
|
public function test_the_login_page_offers_passkeys_when_enabled(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
$client->request('GET', self::ORIGIN.'/');
|
||||||
|
|
||||||
|
$html = (string) $client->getResponse()->getContent();
|
||||||
|
self::assertStringContainsString('id="preauth-passkey"', $html);
|
||||||
|
self::assertStringContainsString('id="preauth-register"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other half: with the feature off the page must not offer anything.
|
||||||
|
* Kept as its own test because a kernel may only be booted once, so the two
|
||||||
|
* configurations cannot be compared within a single test.
|
||||||
|
*/
|
||||||
|
public function test_the_login_page_offers_nothing_when_passkeys_are_disabled(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('preauth-passkey', (string) $client->getResponse()->getContent());
|
||||||
|
self::assertStringNotContainsString('preauth-register', (string) $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CSP must permit the two WebAuthn directives, because they do not fall
|
||||||
|
* back to `default-src` and the browser refuses the ceremony without them.
|
||||||
|
*/
|
||||||
|
public function test_the_csp_permits_the_ceremony_when_passkeys_are_enabled(): void
|
||||||
|
{
|
||||||
|
$client = $this->createPasskeyClient();
|
||||||
|
$client->request('GET', self::ORIGIN.'/');
|
||||||
|
|
||||||
|
$csp = (string) $client->getResponse()->headers->get('Content-Security-Policy');
|
||||||
|
self::assertStringContainsString("publickey-credentials-get 'self'", $csp);
|
||||||
|
self::assertStringContainsString("publickey-credentials-create 'self'", $csp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── helpers ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
private function authCookieFrom(Response $response): \Symfony\Component\HttpFoundation\Cookie
|
||||||
|
{
|
||||||
|
foreach ($response->headers->getCookies() as $cookie) {
|
||||||
|
if (self::AUTH_COOKIE === $cookie->getName()) {
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fail('Expected an auth cookie in the response.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$client = parent::createClient($options, $server);
|
$client = parent::createClient($options, $server);
|
||||||
$client->disableReboot();
|
$client->disableReboot();
|
||||||
|
|
||||||
return $client;
|
return $client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +38,14 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
private function encodePayload(array $data): string
|
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), '+/', '-_'), '=');
|
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── public path accessible without auth ───────────────────────────── */
|
/* ── public path accessible without auth ───────────────────────────── */
|
||||||
|
|
||||||
public function testPublicPathAccessibleWithoutAuthentication(): void
|
public function test_public_path_accessible_without_authentication(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/public/some-repo');
|
$client->request('GET', '/public/some-repo');
|
||||||
@@ -54,7 +56,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
self::assertFalse($response->headers->has('Remote-User'));
|
self::assertFalse($response->headers->has('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPublicPathWithQuerystringAccessible(): void
|
public function test_public_path_with_querystring_accessible(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/public/repo?tab=issues&page=2');
|
$client->request('GET', '/public/repo?tab=issues&page=2');
|
||||||
@@ -62,7 +64,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeepPublicPathAccessible(): void
|
public function test_deep_public_path_accessible(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/public/org/repo/issues/42');
|
$client->request('GET', '/public/org/repo/issues/42');
|
||||||
@@ -72,7 +74,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── non-public path requires auth ─────────────────────────────────── */
|
/* ── non-public path requires auth ─────────────────────────────────── */
|
||||||
|
|
||||||
public function testNonPublicPathShowsLoginPage(): void
|
public function test_non_public_path_shows_login_page(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/private/settings');
|
$client->request('GET', '/private/settings');
|
||||||
@@ -81,7 +83,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
self::assertSelectorExists('form#preauth-form');
|
self::assertSelectorExists('form#preauth-form');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRootPathShowsLoginPage(): void
|
public function test_root_path_shows_login_page(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/');
|
$client->request('GET', '/');
|
||||||
@@ -89,7 +91,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExactPublicPathWithoutSlashNotMatched(): void
|
public function test_exact_public_path_without_slash_not_matched(): void
|
||||||
{
|
{
|
||||||
// /public/** does NOT match /public (no trailing content)
|
// /public/** does NOT match /public (no trailing content)
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
@@ -100,17 +102,17 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── rate limiting ─────────────────────────────────────────────────── */
|
/* ── rate limiting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testRateLimitEnforcedAfterBurstExceeded(): void
|
public function test_rate_limit_enforced_after_burst_exceeded(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
|
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
|
||||||
for ($i = 0; $i < 3; $i++) {
|
for ($i = 0; $i < 3; ++$i) {
|
||||||
$client->request('GET', '/public/repo');
|
$client->request('GET', '/public/repo');
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
200,
|
200,
|
||||||
$client->getResponse()->getStatusCode(),
|
$client->getResponse()->getStatusCode(),
|
||||||
"Request $i should have been allowed"
|
"Request $i should have been allowed",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,12 +127,12 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── authenticated user bypasses public rate limiter ───────────────── */
|
/* ── authenticated user bypasses public rate limiter ───────────────── */
|
||||||
|
|
||||||
public function testAuthenticatedUserBypassesPublicRateLimit(): void
|
public function test_authenticated_user_bypasses_public_rate_limit(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
// First, exhaust the public rate limiter
|
// First, exhaust the public rate limiter
|
||||||
for ($i = 0; $i < 4; $i++) {
|
for ($i = 0; $i < 4; ++$i) {
|
||||||
$client->request('GET', '/public/repo');
|
$client->request('GET', '/public/repo');
|
||||||
}
|
}
|
||||||
// Confirm rate limit is in effect
|
// Confirm rate limit is in effect
|
||||||
@@ -145,10 +147,10 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
$client->request('GET', '/private', [], [], [
|
$client->request('GET', '/private', [], [], [
|
||||||
'HTTP_X-Preauth' => $this->encodePayload([
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
'id' => 'alice',
|
'id' => 'alice',
|
||||||
'token' => $this->validTotpCode(),
|
'token' => $this->validTotpCode(),
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
'json' => true,
|
'json' => true,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
@@ -166,7 +168,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── 200 response has correct content type ─────────────────────────── */
|
/* ── 200 response has correct content type ─────────────────────────── */
|
||||||
|
|
||||||
public function testPublicAccessResponseIsPlainText(): void
|
public function test_public_access_response_is_plain_text(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/public/repo');
|
$client->request('GET', '/public/repo');
|
||||||
@@ -178,12 +180,12 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── 429 response renders error template ───────────────────────────── */
|
/* ── 429 response renders error template ───────────────────────────── */
|
||||||
|
|
||||||
public function testRateLimitedResponseRendersErrorTemplate(): void
|
public function test_rate_limited_response_renders_error_template(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
// Exhaust rate limit
|
// Exhaust rate limit
|
||||||
for ($i = 0; $i < 4; $i++) {
|
for ($i = 0; $i < 4; ++$i) {
|
||||||
$client->request('GET', '/public/repo');
|
$client->request('GET', '/public/repo');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +200,7 @@ final class PublicAccessFlowTest extends WebTestCase
|
|||||||
|
|
||||||
/* ── security headers still applied to public responses ────────────── */
|
/* ── security headers still applied to public responses ────────────── */
|
||||||
|
|
||||||
public function testSecurityHeadersOnPublicAccess(): void
|
public function test_security_headers_on_public_access(): void
|
||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$client->request('GET', '/public/repo');
|
$client->request('GET', '/public/repo');
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Support;
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
use App\ConfigBag;
|
use DateTimeImmutable;
|
||||||
use App\Service\DomainManager;
|
use Override;
|
||||||
use Psr\Log\NullLogger;
|
use Symfony\Component\RateLimiter\LimiterInterface;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
|
||||||
use Symfony\Component\RateLimiter\RateLimit;
|
use Symfony\Component\RateLimiter\RateLimit;
|
||||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
use Symfony\Component\RateLimiter\LimiterInterface;
|
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
use Twig\Loader\FilesystemLoader;
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
|
||||||
@@ -25,26 +23,29 @@ trait ListenerTestHelper
|
|||||||
/** Build a Twig Environment pointed at the project's real templates. */
|
/** Build a Twig Environment pointed at the project's real templates. */
|
||||||
private function makeTwig(): Environment
|
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]);
|
$twig = new Environment($loader, ['strict_variables' => true]);
|
||||||
// the templates reference a global `env` object; supply one with the
|
// the templates reference a global `env` object; supply one with the
|
||||||
// keys used by base/login/error/_script/_style
|
// keys used by base/login/error/_script/_style
|
||||||
$twig->addGlobal('env', (object)[
|
$twig->addGlobal('env', (object) [
|
||||||
'title' => 'Pre-Authentication System',
|
'title' => 'Pre-Authentication System',
|
||||||
'bg_color' => '#029386',
|
'bg_color' => '#029386',
|
||||||
'fg_color' => '#ffffff',
|
'fg_color' => '#ffffff',
|
||||||
'error_color' => '#ffb16d',
|
'error_color' => '#ffb16d',
|
||||||
'id_name' => 'Session ID',
|
'id_name' => 'Session ID',
|
||||||
'token_name' => 'Authentication Token',
|
'token_name' => 'Authentication Token',
|
||||||
'submit_name' => 'Submit',
|
'submit_name' => 'Submit',
|
||||||
'error_message' => 'Unsuccessful login attempt',
|
'error_message' => 'Unsuccessful login attempt',
|
||||||
'teapot' => true,
|
'teapot' => true,
|
||||||
'teapot_title' => "I'm a teapot",
|
'teapot_title' => "I'm a teapot",
|
||||||
'teapot_message' => 'I refuse to brew coffee',
|
'teapot_message' => 'I refuse to brew coffee',
|
||||||
'too_many_title' => 'Too many requests',
|
'too_many_title' => 'Too many requests',
|
||||||
'too_many_message' => 'Try again later',
|
'too_many_message' => 'Try again later',
|
||||||
'debug' => 0,
|
'passkey_button_name' => 'Sign in with a passkey',
|
||||||
|
'passkey_register_name' => 'Register this device as a passkey',
|
||||||
|
'debug' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $twig;
|
return $twig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +56,13 @@ trait ListenerTestHelper
|
|||||||
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
|
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
|
||||||
{
|
{
|
||||||
$limiter = $this->makeLimiter($remainingTokens);
|
$limiter = $this->makeLimiter($remainingTokens);
|
||||||
return new class ($limiter) implements RateLimiterFactoryInterface {
|
|
||||||
|
return new class($limiter) implements RateLimiterFactoryInterface {
|
||||||
public function __construct(private LimiterInterface $limiter)
|
public function __construct(private LimiterInterface $limiter)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function create(?string $key = null): LimiterInterface
|
public function create(?string $key = null): LimiterInterface
|
||||||
{
|
{
|
||||||
return $this->limiter;
|
return $this->limiter;
|
||||||
@@ -70,22 +74,29 @@ trait ListenerTestHelper
|
|||||||
{
|
{
|
||||||
$rateLimit = new RateLimit(
|
$rateLimit = new RateLimit(
|
||||||
$remainingTokens,
|
$remainingTokens,
|
||||||
new \DateTimeImmutable('+10 seconds'),
|
new DateTimeImmutable('+10 seconds'),
|
||||||
$remainingTokens > 0,
|
$remainingTokens > 0,
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
return new class ($rateLimit) implements LimiterInterface {
|
|
||||||
|
return new class($rateLimit) implements LimiterInterface {
|
||||||
public function __construct(private RateLimit $rateLimit)
|
public function __construct(private RateLimit $rateLimit)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||||
{
|
{
|
||||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function consume(int $tokens = 1): RateLimit
|
public function consume(int $tokens = 1): RateLimit
|
||||||
{
|
{
|
||||||
return $this->rateLimit;
|
return $this->rateLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -98,35 +109,46 @@ trait ListenerTestHelper
|
|||||||
*/
|
*/
|
||||||
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
|
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
|
||||||
{
|
{
|
||||||
$limiter = new class ($threshold) implements LimiterInterface {
|
$limiter = new class($threshold) implements LimiterInterface {
|
||||||
private int $consumed = 0;
|
private int $consumed = 0;
|
||||||
|
|
||||||
public function __construct(private int $threshold)
|
public function __construct(private int $threshold)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||||
{
|
{
|
||||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function consume(int $tokens = 1): RateLimit
|
public function consume(int $tokens = 1): RateLimit
|
||||||
{
|
{
|
||||||
$this->consumed += $tokens;
|
$this->consumed += $tokens;
|
||||||
$remaining = max(0, $this->threshold - $this->consumed);
|
$remaining = max(0, $this->threshold - $this->consumed);
|
||||||
|
|
||||||
return new RateLimit(
|
return new RateLimit(
|
||||||
$remaining,
|
$remaining,
|
||||||
new \DateTimeImmutable('+10 seconds'),
|
new DateTimeImmutable('+10 seconds'),
|
||||||
$remaining > 0,
|
$remaining > 0,
|
||||||
$this->threshold,
|
$this->threshold,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->consumed = 0;
|
$this->consumed = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return new class ($limiter) implements RateLimiterFactoryInterface {
|
|
||||||
|
return new class($limiter) implements RateLimiterFactoryInterface {
|
||||||
public function __construct(private LimiterInterface $limiter)
|
public function __construct(private LimiterInterface $limiter)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function create(?string $key = null): LimiterInterface
|
public function create(?string $key = null): LimiterInterface
|
||||||
{
|
{
|
||||||
return $this->limiter;
|
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;
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
use App\ConfigBag;
|
use App\ConfigBag;
|
||||||
use App\Enum\RemoteUserMode;
|
|
||||||
use App\Utilities;
|
use App\Utilities;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use OTPHP\TOTP;
|
use OTPHP\TOTP;
|
||||||
use PHPUnit\Framework\TestCase;
|
use Override;
|
||||||
use Psr\Cache\CacheItemInterface;
|
use Psr\Cache\CacheItemInterface;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Clock\ClockInterface as PsrClockInterface;
|
use Psr\Clock\ClockInterface as PsrClockInterface;
|
||||||
@@ -31,10 +30,13 @@ trait TotpTestHelper
|
|||||||
private function frozenClock(): PsrClockInterface
|
private function frozenClock(): PsrClockInterface
|
||||||
{
|
{
|
||||||
$time = self::FROZEN_TIME;
|
$time = self::FROZEN_TIME;
|
||||||
return new class ($time) implements PsrClockInterface {
|
|
||||||
|
return new class($time) implements PsrClockInterface {
|
||||||
public function __construct(private string $time)
|
public function __construct(private string $time)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function now(): DateTimeImmutable
|
public function now(): DateTimeImmutable
|
||||||
{
|
{
|
||||||
return new DateTimeImmutable($this->time);
|
return new DateTimeImmutable($this->time);
|
||||||
@@ -47,6 +49,7 @@ trait TotpTestHelper
|
|||||||
{
|
{
|
||||||
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
|
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
|
||||||
$totp->setLabel('Test-TOTP');
|
$totp->setLabel('Test-TOTP');
|
||||||
|
|
||||||
return $totp->getProvisioningUri();
|
return $totp->getProvisioningUri();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +79,15 @@ trait TotpTestHelper
|
|||||||
string $remoteUserMode = 'session',
|
string $remoteUserMode = 'session',
|
||||||
string $remoteUserStatic = 'authenticated',
|
string $remoteUserStatic = 'authenticated',
|
||||||
string $remoteUserMap = '',
|
string $remoteUserMap = '',
|
||||||
|
string $title = 'Pre-Authentication System',
|
||||||
|
bool $passkeyEnabled = false,
|
||||||
|
string $passkeyRpName = '',
|
||||||
|
string $passkeyUserVerification = 'required',
|
||||||
|
int $passkeyTimeout = 60000,
|
||||||
): ConfigBag {
|
): ConfigBag {
|
||||||
$clock = $this->frozenClock();
|
$clock = $this->frozenClock();
|
||||||
$utilities = $this->createUtilities($clock);
|
$utilities = $this->createUtilities($clock);
|
||||||
|
|
||||||
return new ConfigBag(
|
return new ConfigBag(
|
||||||
$utilities,
|
$utilities,
|
||||||
$clock,
|
$clock,
|
||||||
@@ -92,6 +101,11 @@ trait TotpTestHelper
|
|||||||
$remoteUserMode,
|
$remoteUserMode,
|
||||||
$remoteUserStatic,
|
$remoteUserStatic,
|
||||||
$remoteUserMap,
|
$remoteUserMap,
|
||||||
|
$title,
|
||||||
|
$passkeyEnabled,
|
||||||
|
$passkeyRpName,
|
||||||
|
$passkeyUserVerification,
|
||||||
|
$passkeyTimeout,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +121,7 @@ trait TotpTestHelper
|
|||||||
$item = $this->createStub(CacheItemInterface::class);
|
$item = $this->createStub(CacheItemInterface::class);
|
||||||
$item->method('isHit')->willReturn(false);
|
$item->method('isHit')->willReturn(false);
|
||||||
$cache->method('getItem')->willReturn($item);
|
$cache->method('getItem')->willReturn($item);
|
||||||
|
|
||||||
return new Utilities($clock, $cache);
|
return new Utilities($clock, $cache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ class TestKernel extends AppKernel
|
|||||||
{
|
{
|
||||||
parent::build($container);
|
parent::build($container);
|
||||||
|
|
||||||
$container->addCompilerPass(new class () implements CompilerPassInterface {
|
$container->addCompilerPass(new class implements CompilerPassInterface {
|
||||||
public function process(ContainerBuilder $container): void
|
public function process(ContainerBuilder $container): void
|
||||||
{
|
{
|
||||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
|
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache', 'passkeyRateLimitCache'] as $poolId) {
|
||||||
if ($container->hasDefinition($poolId)) {
|
if ($container->hasDefinition($poolId)) {
|
||||||
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user