Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd06838d3 | ||
|
|
54990dafc6 | ||
|
|
06e2ca8b19 | ||
|
|
4d5afa20e9 | ||
|
|
74902c0fc7 | ||
|
|
bd2f5cbac3 | ||
|
|
51e921f54f | ||
|
|
ba8924a244 | ||
|
|
538bd74100 | ||
|
|
db0cf77049 | ||
|
|
ef924472a3 | ||
|
|
539385c438 | ||
|
|
eed6b4dbff | ||
|
|
2b61a43f60 | ||
|
|
abe94c6238 | ||
|
|
1e186c9354 | ||
|
|
2064153cd3 | ||
|
|
054b8ef48f | ||
|
|
5258e175a1 | ||
|
|
2f7ae31ba1 | ||
|
|
baf976a8e6 | ||
|
|
af4d2a4ac7 | ||
|
|
c743a1baac | ||
|
|
3f1778cd6b | ||
|
|
33181f11d8 | ||
|
|
bb2cc3ce49 | ||
|
|
e4f54769e6 | ||
|
|
b75a16a781 | ||
|
|
9111958bcf | ||
|
|
95dc6bf0ce | ||
|
|
472abfdf89 | ||
|
|
e2780ca5f6 |
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
|||||||
@@ -25,6 +25,104 @@ 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.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to
|
||||||
|
`8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean
|
||||||
|
(test suite runs with `failOnDeprecation`), so the major-version jump
|
||||||
|
required no application code changes. See
|
||||||
|
`docs/symfony-8.1-upgrade-plan.md`.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **`runtime/frankenphp-symfony`** — No longer needed: `symfony/runtime`
|
||||||
|
8.1 handles FrankenPHP worker mode natively via its built-in
|
||||||
|
`FrankenPhpWorkerRunner`. The `extra.runtime` override in
|
||||||
|
`composer.json` was removed so the runtime auto-detects FrankenPHP.
|
||||||
|
The old package's `FRANKENPHP_LOOP_MAX` env var is no longer read;
|
||||||
|
an equivalent recycle limit is restored via the new `MAX_REQUESTS`
|
||||||
|
setting below.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`MAX_REQUESTS` worker-thread recycle limit** — The `Caddyfile` now
|
||||||
|
sets FrankenPHP's native `max_requests` from the `MAX_REQUESTS`
|
||||||
|
environment variable: each PHP worker thread is gracefully restarted
|
||||||
|
after N requests while others keep serving, containing slow memory
|
||||||
|
growth across long uptime. The image default is **500** (matching the
|
||||||
|
previous `runtime/frankenphp-symfony` default), baked in as a Docker
|
||||||
|
build arg and overridable at runtime (`MAX_REQUESTS=0` disables
|
||||||
|
restarts). Arbitrary `frankenphp`-block configuration is still
|
||||||
|
possible via the stock `FRANKENPHP_CONFIG` env var.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Login flow responses are no longer cacheable** — the login page,
|
||||||
|
failed logins, redirects, and rate-limit/error pages now send strict
|
||||||
|
anti-caching headers (`Cache-Control: no-store, no-cache,
|
||||||
|
must-revalidate, proxy-revalidate, max-age=0, s-maxage=0` plus
|
||||||
|
`Pragma`, `Expires`, `Surrogate-Control`, and `Vary: *`), the login
|
||||||
|
form's `fetch()` bypasses the HTTP cache, and the example Caddyfile
|
||||||
|
guards every `forward_auth` block with matching `header_down` rules.
|
||||||
|
This prevents browsers — notably older Safari — from replaying a stale
|
||||||
|
pre-auth response on refresh (previously: log in successfully, refresh,
|
||||||
|
and land back on the login page). Successful (2xx) responses are
|
||||||
|
deliberately excluded: they are consumed by the proxy's `forward_auth`
|
||||||
|
check and never reach the browser.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dockerfile rebuild — same layout as the rest of the portfolio** (Guiding
|
||||||
|
Light §6.4). The build now copies the tree (`COPY . .`) and lets
|
||||||
|
`.dockerignore` decide what reaches the context, instead of maintaining a
|
||||||
|
hand-written `COPY ./x /app/x` allowlist that had to be kept in step with
|
||||||
|
the project layout. `var/` — which the old file list never copied — now
|
||||||
|
simply stays out via the ignore file.
|
||||||
|
- **The image runs as a non-root `app` user** (uid/gid 1000, the same
|
||||||
|
convention as task-loom/context-shuttle). `/data` (cache pools) and
|
||||||
|
`/config` are created and owned by it. This resolves the last failing
|
||||||
|
conformance check (§6.4 `dockerfile-nonroot`).
|
||||||
|
- **`.dockerignore` rebuilt on the Guiding Light §6.2 baseline** — in
|
||||||
|
particular `.env` is now excluded explicitly (§6.1), so a developer's
|
||||||
|
local environment file can never be baked into a layer.
|
||||||
|
- **`docker/php.ini` and `docker/Caddyfile` added.** The PHP overrides
|
||||||
|
(`expose_php=Off`, error/log settings, OPcache timestamps off, APCu for
|
||||||
|
CLI) and the FrankenPHP app config now live in the repository instead of
|
||||||
|
being three heredocs inside the Dockerfile, so what the image runs is
|
||||||
|
reviewable in a diff.
|
||||||
|
- **Runtime base image pinned to `dunglas/frankenphp:1-php8.5-trixie` and
|
||||||
|
APCu installed via the base image's `install-php-extensions`** — the
|
||||||
|
versioned tag replaces the floating one, and the build no longer drags a
|
||||||
|
compiler toolchain into the runtime layer to build one extension.
|
||||||
|
- **`/app` is now the whole project.** The old image only shipped
|
||||||
|
`bin/console`, `config`, `public`, `src`, `templates` and the composer
|
||||||
|
manifests; `config/reference.php` and other loose files are now present.
|
||||||
|
No application path changes: `public/index.php` and `bin/console` resolve
|
||||||
|
through the same relative paths.
|
||||||
|
- `bin/franken.sh` mounts the share dir at its new default
|
||||||
|
(`/app/var/share`) instead of the old `/app/var/share` bind that no longer
|
||||||
|
matched the image.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`composer dump-env prod --empty` removed.** preauth does not depend on
|
||||||
|
`symfony/dotenv` (it is not in `composer.lock`), so nothing reads a `.env`
|
||||||
|
file in the container — the command only produced a dead
|
||||||
|
`.env.local.php` in the build stage. The Dockerfile comment that claimed
|
||||||
|
otherwise is gone with it.
|
||||||
|
- **`composer install` no longer ships a classmap missing `App\`.** The old
|
||||||
|
build ran `install --optimize-autoloader` before `src/` was copied, and the
|
||||||
|
final `--classmap-authoritative` dump happened before any `COPY . .`; the
|
||||||
|
classmap is now rebuilt after the application is in place.
|
||||||
|
- **The HEALTHCHECK can actually pass.** It probed `curl -f http://localhost/`,
|
||||||
|
and preauth answers every unauthenticated request to `/` with the login page
|
||||||
|
and a `401` — so the probe failed 100% of the time and the container was
|
||||||
|
permanently marked unhealthy. It now probes Caddy's loopback admin endpoint
|
||||||
|
(the base image's own default probe, restated explicitly), which is why the
|
||||||
|
Caddyfile deliberately does not disable the admin API.
|
||||||
|
- **`expose_php` is now genuinely off in the runtime image.** The base image
|
||||||
|
ships the `php.ini-production` *template* but no active `php.ini`, so the
|
||||||
|
previous `cp` of the template was the only thing setting it — and the
|
||||||
|
`docker/php.ini` overrides are loaded after it, so stating it here makes the
|
||||||
|
intent explicit; verified against a real boot that no `X-Powered-By` header
|
||||||
|
is emitted.
|
||||||
|
- `bin/franken.sh` no longer passes `DEFAULT_URI`, which the application does
|
||||||
|
not read (`config/packages/routing.yaml` sets the router's `default_uri`).
|
||||||
|
|
||||||
## [1.0.0] — v1.0 Release
|
## [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
|
||||||
|
|||||||
+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.
|
||||||
+2
-2
@@ -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
|
||||||
@@ -329,7 +329,7 @@ struggle with TOTP apps.
|
|||||||
command or initial-setup flow to register a passkey).
|
command or initial-setup flow to register a passkey).
|
||||||
|
|
||||||
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
||||||
7.4 and FrankenPHP
|
8.1 and FrankenPHP
|
||||||
- [ ] Design passkey registration flow (console command? first-visit
|
- [ ] Design passkey registration flow (console command? first-visit
|
||||||
setup? separate registration endpoint?)
|
setup? separate registration endpoint?)
|
||||||
- [ ] Implement challenge generation and storage (extend existing
|
- [ ] Implement challenge generation and storage (extend existing
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| unreleased (v1 development) | ✅ |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Report vulnerabilities privately to **security@digitaladapt.com** (or open a private
|
||||||
|
security advisory on the repository). Please include reproduction steps and affected
|
||||||
|
versions. You will receive an acknowledgement within 48 hours and a status update at
|
||||||
|
least weekly until resolution.
|
||||||
|
|
||||||
|
**Do not open a public issue for a suspected vulnerability.** preauth is an
|
||||||
|
authentication gateway — it sits in front of every protected service, so a
|
||||||
|
weakness here is a weakness everywhere behind it.
|
||||||
|
|
||||||
|
## Security model summary
|
||||||
|
|
||||||
|
preauth implements the auth half of the `forward_auth` pattern: a reverse proxy
|
||||||
|
calls it per request to decide whether a request may reach the upstream service.
|
||||||
|
|
||||||
|
- **Two outcomes per request: allow or intercept.** `AcceptListener` /
|
||||||
|
`RejectListener` / `InterceptListener` decide, and the decision is made on
|
||||||
|
every request rather than cached — a cached auth session is an anti-pattern
|
||||||
|
(GUIDING-LIGHT §3.3d), which is also why this project gets **no service
|
||||||
|
worker**.
|
||||||
|
- **The login flow is never cached.** The login page, failed logins, redirects
|
||||||
|
and rate-limit responses are sent with
|
||||||
|
`Cache-Control: no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0`.
|
||||||
|
An aggressive cache (notably older Safari) replaying a stale pre-auth response
|
||||||
|
presents to the user as being logged back out after a refresh.
|
||||||
|
- **Headers are set by the app, not left to the proxy.** `X-Content-Type-Options:
|
||||||
|
nosniff`, `X-Frame-Options: DENY`, a Content-Security-Policy, and
|
||||||
|
`Strict-Transport-Security: max-age=31536000`. Docs recommend mirroring the
|
||||||
|
caching headers at the edge as defence in depth, but the app does not depend
|
||||||
|
on it.
|
||||||
|
- **TOTP is required.** Secrets come from `TOTP_URI`; if it is unset the app
|
||||||
|
generates one and prints it for enrolment. Login state is carried in a signed
|
||||||
|
payload (`src/Data/Payload.php`) bound to a nonce and a scope, not in a
|
||||||
|
server-side session store.
|
||||||
|
- **Rate limiting is on by default**, with the block response configurable
|
||||||
|
(`TEAPOT=false` returns 429 rather than 418).
|
||||||
|
- **`REMOTE_USER` is trusted input, not a secret.** In `remote_user` modes the
|
||||||
|
gateway accepts an upstream-asserted identity, so the upstream must be the
|
||||||
|
only path to the app. Do not expose preauth directly to the internet for this
|
||||||
|
mode.
|
||||||
|
- **`.env` is never committed; secrets are env vars injected at runtime.** Real
|
||||||
|
secrets belong in `.env.local` or `bin/console secrets:set`, read via
|
||||||
|
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope: the application code in `src/`, the shipped `Caddyfile`, the
|
||||||
|
`Dockerfile`, and anything that affects the allow/intercept decision.
|
||||||
|
|
||||||
|
Out of scope: the `forward_auth` integration at the edge (a host-proxy
|
||||||
|
configuration concern, see `docs/examples/Caddyfile`) and the security of the
|
||||||
|
services preauth protects.
|
||||||
|
|
||||||
|
## Deployment note
|
||||||
|
|
||||||
|
preauth runs as a container and drops privileges via `USER` (Guiding Light
|
||||||
|
§6.4): the image runs as the non-root `app` user (uid/gid 1000) and owns the
|
||||||
|
state paths it needs. Only `/data` is written at runtime — the cache pools
|
||||||
|
behind sessions, backup codes and rate limiting — and `/config` is declared
|
||||||
|
because the base image points Caddy's XDG config dir there. If you pin a
|
||||||
|
different `user:` in your compose file, that user must be able to write to
|
||||||
|
both paths — otherwise login state and backup codes cannot be persisted.
|
||||||
+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
|
||||||
|
|||||||
+19
-19
@@ -4,22 +4,21 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.4",
|
"php": "^8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"bacon/bacon-qr-code": "^3.1.1",
|
"bacon/bacon-qr-code": "^3.1.1",
|
||||||
"runtime/frankenphp-symfony": "^1.0.0",
|
|
||||||
"spomky-labs/otphp": "^11.4.2",
|
"spomky-labs/otphp": "^11.4.2",
|
||||||
"symfony/cache": "7.4.*",
|
"symfony/cache": "8.1.*",
|
||||||
"symfony/console": "7.4.*",
|
"symfony/console": "8.1.*",
|
||||||
"symfony/flex": "^2.11",
|
"symfony/flex": "^2.11",
|
||||||
"symfony/framework-bundle": "7.4.*",
|
"symfony/framework-bundle": "8.1.*",
|
||||||
"symfony/mime": "7.4.*",
|
"symfony/mime": "8.1.*",
|
||||||
"symfony/rate-limiter": "7.4.*",
|
"symfony/rate-limiter": "8.1.*",
|
||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "8.1.*",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/twig-bundle": "8.1.*",
|
||||||
"symfony/uid": "7.4.*",
|
"symfony/uid": "8.1.*",
|
||||||
"symfony/yaml": "7.4.*"
|
"symfony/yaml": "8.1.*"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -28,7 +27,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 +68,16 @@
|
|||||||
"symfony/symfony": "*"
|
"symfony/symfony": "*"
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"runtime": {
|
|
||||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
|
||||||
},
|
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.4.*"
|
"require": "8.1.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"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
+787
-807
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],
|
||||||
|
|||||||
+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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Preauth build config
|
||||||
|
#
|
||||||
|
# CI (develop.yaml / docker.yaml) invokes this with --file so the compose
|
||||||
|
# file that lives in the same directory is not merged in as extra targets.
|
||||||
|
#
|
||||||
|
# DOCKERHUB_TARGET is the org/repo (Gitea Settings → Variables)
|
||||||
|
# CI sets TAG=latest + VERSION=<v-stripped> for tag pushes,
|
||||||
|
# TAG=develop for pushes to main.
|
||||||
|
#
|
||||||
|
# MAX_REQUESTS=0 docker buildx bake # specify variables to override
|
||||||
|
|
||||||
|
variable "DOCKERHUB_TARGET" {
|
||||||
|
default = "digitaladapt/preauth"
|
||||||
|
description = "Docker Hub repo/org (Gitea repo variable DOCKERHUB_TARGET)."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "TAG" {
|
||||||
|
default = "latest"
|
||||||
|
description = "Base tag for this build: latest (release), develop (main push), or a version."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "VERSION" {
|
||||||
|
default = ""
|
||||||
|
description = "Full version (v stripped) to also tag with; empty for develop builds."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "MAX_REQUESTS" {
|
||||||
|
default = "500"
|
||||||
|
description = "Restart each FrankenPHP worker thread after N requests (0 disables). Baked in at build time; the same env var overrides it at runtime."
|
||||||
|
}
|
||||||
|
|
||||||
|
group "default" {
|
||||||
|
targets = ["app"]
|
||||||
|
}
|
||||||
|
|
||||||
|
target "app" {
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
target = "app"
|
||||||
|
context = "."
|
||||||
|
platforms = ["linux/amd64", "linux/arm64"]
|
||||||
|
|
||||||
|
# Layer cache. The shared docker-publish.yaml sets cache-from/cache-to for
|
||||||
|
# its `action` backend but NOT for `bake`, so specifying it here is what keeps
|
||||||
|
# CI builds warm. preauth compiles APCu from source (pecl) in both stages, so
|
||||||
|
# a cold build is expensive.
|
||||||
|
#
|
||||||
|
# Local builds outside CI have no GHA cache service, so override:
|
||||||
|
# docker buildx bake --set 'app.cache-to=' --set 'app.cache-from='
|
||||||
|
cache-from = ["type=gha"]
|
||||||
|
cache-to = ["type=gha,mode=max"]
|
||||||
|
|
||||||
|
# The Dockerfile declares ARG MAX_REQUESTS=500 for plain `docker build`.
|
||||||
|
# It is repeated explicitly here so CI's value is visible and can be changed
|
||||||
|
# in this file instead of in a workflow. Keep the two defaults in sync.
|
||||||
|
args = {
|
||||||
|
MAX_REQUESTS = "${MAX_REQUESTS}"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = concat(
|
||||||
|
["${DOCKERHUB_TARGET}:${TAG}"],
|
||||||
|
VERSION != "" ? ["${DOCKERHUB_TARGET}:${VERSION}"] : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -24,6 +24,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)
|
||||||
@@ -1,9 +1,34 @@
|
|||||||
|
# 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
|
# example of securing full service
|
||||||
# TODO replace domain and service name and port
|
# TODO replace domain and service name and port
|
||||||
service.example.com {
|
service.example.com {
|
||||||
forward_auth preauth {
|
forward_auth preauth {
|
||||||
uri {uri}
|
uri {uri}
|
||||||
copy_headers Remote-User
|
copy_headers Remote-User
|
||||||
|
import preauth_no_store
|
||||||
}
|
}
|
||||||
reverse_proxy service-container:80
|
reverse_proxy service-container:80
|
||||||
}
|
}
|
||||||
@@ -16,6 +41,7 @@ protected.example.com {
|
|||||||
forward_auth /secure/* preauth {
|
forward_auth /secure/* preauth {
|
||||||
uri {uri}
|
uri {uri}
|
||||||
copy_headers Remote-User
|
copy_headers Remote-User
|
||||||
|
import preauth_no_store
|
||||||
}
|
}
|
||||||
reverse_proxy protected-service:9000
|
reverse_proxy protected-service:9000
|
||||||
}
|
}
|
||||||
@@ -39,6 +65,7 @@ git.example.com {
|
|||||||
forward_auth preauth {
|
forward_auth preauth {
|
||||||
uri {uri}
|
uri {uri}
|
||||||
copy_headers Remote-User
|
copy_headers Remote-User
|
||||||
|
import preauth_no_store
|
||||||
}
|
}
|
||||||
reverse_proxy gitea:3000
|
reverse_proxy gitea:3000
|
||||||
}
|
}
|
||||||
@@ -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,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,985 @@
|
|||||||
|
parameters:
|
||||||
|
ignoreErrors:
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Clock\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Clock.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:configure\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:configure\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Command/GenerateBackupCodesCommand.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:execute\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:execute\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Command/GenerateBackupCodesCommand.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$id\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$json\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$nonce\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$scope\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Data\\Payload has an uninitialized property \$token\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Data\\Payload\:\:load\(\) has parameter \$input with generic class Symfony\\Component\\HttpFoundation\\InputBag but does not specify its types\: TInput$#'
|
||||||
|
identifier: missingType.generics
|
||||||
|
count: 1
|
||||||
|
path: src/Data/Payload.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Kernel has an uninitialized property \$persistCache\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: src/Kernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Kernel\:\:boot\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:boot\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Kernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Kernel\:\:terminate\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:terminate\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Kernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\AcceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AcceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\AcceptListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AcceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\AllowListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AllowListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\AllowListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/AllowListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Property App\\Listener\\InterceptListener\:\:\$config is never read, only written\.$#'
|
||||||
|
identifier: property.onlyWritten
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 2
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/InterceptListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 2
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||||
|
identifier: nullsafe.neverNull
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Using nullsafe property access "\?\-\>json" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||||
|
identifier: nullsafe.neverNull
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/LoginListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\PublicAccessListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/PublicAccessListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\PublicAccessListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/PublicAccessListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Using nullsafe method call on non\-nullable type DateTimeImmutable\. Use \-\> instead\.$#'
|
||||||
|
identifier: nullsafe.neverNull
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/PublicAccessListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Listener\\RejectListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/RejectListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Listener\\RejectListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Listener/RejectListener.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:allValid\(\) has parameter \$keys with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getChanges\(\) return type has no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:getKeys\(\) return type has no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\MonitorCacheKeys\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/MonitorCacheKeys.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:expire\(\) overrides method App\\Service\\BackupCodeInterface\:\:expire\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:generate\(\) overrides method App\\Service\\BackupCodeInterface\:\:generate\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:saveCodes\(\) has parameter \$codes with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\BackupCodeManager\:\:verifyAndConsume\(\) overrides method App\\Service\\BackupCodeInterface\:\:verifyAndConsume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$config is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/BackupCodeManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:authBase\(\) overrides method App\\Service\\DomainInterface\:\:authBase\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:getAuthSubdomain\(\) overrides method App\\Service\\DomainInterface\:\:getAuthSubdomain\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:matchesAuth\(\) overrides method App\\Service\\DomainInterface\:\:matchesAuth\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\DomainManager\:\:validReturn\(\) overrides method App\\Service\\DomainInterface\:\:validReturn\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/DomainManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\LoginManager\:\:checkToken\(\) overrides method App\\Service\\LoginInterface\:\:checkToken\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\LoginManager\:\:\$config is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\LoginManager\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Service\\LoginManager\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: src/Service/LoginManager.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\PublicPathMatcher\:\:isEmpty\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:isEmpty\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/PublicPathMatcher.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Service\\PublicPathMatcher\:\:matches\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:matches\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: src/Service/PublicPathMatcher.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:loginPayload\(\) is unused\.$#'
|
||||||
|
identifier: method.unused
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/AuthenticationFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/CacheControlFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Functional/PublicAccessFlowTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\TestKernel\:\:build\(\) overrides method Symfony\\Component\\DependencyInjection\\Kernel\\AbstractKernel\:\:build\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/TestKernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface@anonymous/tests/TestKernel\.php\:31\:\:process\(\) overrides method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface\:\:process\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/TestKernel.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Unit\\Command\\GenerateBackupCodesCommandTest\:\:makeManagerStub\(\) has parameter \$generatedCodes with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Command/GenerateBackupCodesCommandTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/ConfigBagRemoteUserTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#'
|
||||||
|
identifier: staticMethod.alreadyNarrowedType
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Enum/ScopeTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/AcceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/AllowListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method App\\Tests\\Unit\\Listener\\LoginListenerTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/LoginListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||||
|
identifier: arguments.count
|
||||||
|
count: 2
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Listener/RejectListenerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#'
|
||||||
|
identifier: staticMethod.alreadyNarrowedType
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
|
||||||
|
identifier: staticMethod.alreadyNarrowedType
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
|
||||||
|
identifier: method.notFound
|
||||||
|
count: 17
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$backupCodeManager\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$domainManager\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$pool\. Give it default value or assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitialized
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Service/LoginManagerTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22 has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22\:\:\$config is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class App\\Tests\\Unit\\Trait\\HasLoggerTraitTest has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/HasLoggerTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property App\\Tests\\Unit\\Trait\\HasLoggerTraitTest\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/HasLoggerTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||||
|
identifier: property.uninitializedReadonly
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAfter\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAfter\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAt\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAt\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:get\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:get\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:getKey\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:getKey\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:isHit\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:isHit\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:set\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:set\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) return type has no value type specified in iterable type iterable\.$#'
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$logger is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||||
|
identifier: property.readOnlyAssignNotInConstructor
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||||
|
identifier: method.missingOverride
|
||||||
|
count: 1
|
||||||
|
path: tests/Unit/Trait/StringTraitTest.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#'
|
||||||
|
identifier: function.impossibleType
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Call to method bootEnv\(\) on an unknown class Symfony\\Component\\Dotenv\\Dotenv\.$#'
|
||||||
|
identifier: class.notFound
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
|
||||||
|
identifier: class.notFound
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
|
|
||||||
|
-
|
||||||
|
message: '#^Instantiated class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
|
||||||
|
identifier: class.notFound
|
||||||
|
count: 1
|
||||||
|
path: tests/bootstrap.php
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# phpstan.neon.dist — canonical shared PHPStan config.
|
||||||
|
#
|
||||||
|
# Copy verbatim into a project root. This is a LEAF file: it has no
|
||||||
|
# project-specific content, so "sync it" means "overwrite it", never merge.
|
||||||
|
# Do not hand-edit per repo — change it here and re-sync, or the five copies
|
||||||
|
# drift back apart (GUIDING-LIGHT §8.2).
|
||||||
|
#
|
||||||
|
# Baseline: level 6 for application code. Raise per project as it gets clean;
|
||||||
|
# the goal recorded in GUIDING-LIGHT §2.2 is level 6 minimum everywhere.
|
||||||
|
#
|
||||||
|
# Adopt incrementally:
|
||||||
|
# 1. vendor/bin/phpstan analyse --generate-baseline
|
||||||
|
# 2. Commit the result over the empty phpstan-baseline.neon that ships with this
|
||||||
|
# 3. Ratchet `level` up as the baseline shrinks
|
||||||
|
# Never replace a fix with an ignoreErrors entry — see reportIgnoresWithoutComments.
|
||||||
|
#
|
||||||
|
# IMPORTANT — every key below is VERIFIED against phpstan.org/config-reference.
|
||||||
|
# PHPStan 2.x errors on unknown keys, but a plausible-looking wrong key copied
|
||||||
|
# from a blog post is a common way to lose an afternoon. If you add a key,
|
||||||
|
# confirm it there first. Extension-specific keys (symfony.*, doctrine.*,
|
||||||
|
# phpstan-deprecation-rules, etc.) are deliberately NOT set here — see the
|
||||||
|
# commented block at the bottom for why and how to opt in per project.
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
level: 6
|
||||||
|
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
- tests
|
||||||
|
|
||||||
|
# ── High-signal checks (all verified key names) ──────────────────────────
|
||||||
|
|
||||||
|
# An `@var` that contradicts the assignment is almost always a real bug.
|
||||||
|
reportWrongPhpDocTypeInVarTag: true
|
||||||
|
|
||||||
|
# A `@var` that only widens the inferred type is usually an unnecessary cast.
|
||||||
|
reportAnyTypeWideningInVarTag: true
|
||||||
|
|
||||||
|
# `@param`/`@return` that contradict the native signature.
|
||||||
|
reportStaticMethodSignatures: true
|
||||||
|
|
||||||
|
# An ignoreErrors entry with no explanatory comment is a smell.
|
||||||
|
reportIgnoresWithoutComments: true
|
||||||
|
|
||||||
|
# Forces every ignore to still match something. Without this, ignores
|
||||||
|
# accumulate forever and nobody notices when the underlying bug is fixed.
|
||||||
|
reportUnmatchedIgnoredErrors: true
|
||||||
|
|
||||||
|
# Catch `Foo` vs `foo` in function names — matters for Windows devs and
|
||||||
|
# for correctness under strict autoloading.
|
||||||
|
checkFunctionNameCase: true
|
||||||
|
|
||||||
|
# Typed properties that are read before they are definitely initialised.
|
||||||
|
checkUninitializedProperties: true
|
||||||
|
|
||||||
|
# Respect #[Override] so refactors in parent classes cannot silently stop
|
||||||
|
# overriding a method that got renamed.
|
||||||
|
checkMissingOverrideMethodAttribute: true
|
||||||
|
checkMissingOverridePropertyAttribute: true
|
||||||
|
|
||||||
|
# Static analysis cannot see through sprintf, so mis-ordered placeholders
|
||||||
|
# are otherwise invisible until runtime.
|
||||||
|
checkStrictPrintfPlaceholderTypes: true
|
||||||
|
|
||||||
|
# Dynamic properties are deprecated in PHP 8.2+ and are a common source of
|
||||||
|
# typos that would otherwise fail silently at runtime.
|
||||||
|
checkDynamicProperties: true
|
||||||
|
|
||||||
|
# All five repos have this file (verified); the kernel boot lives here.
|
||||||
|
bootstrapFiles:
|
||||||
|
- tests/bootstrap.php
|
||||||
|
|
||||||
|
ignoreErrors:
|
||||||
|
# Symfony's createClient() returns KernelBrowser, but some test helpers
|
||||||
|
# are typed against the narrower legacy interface.
|
||||||
|
# reportUnmatched:false so this does not fail the build once the
|
||||||
|
# offending helper is typed properly.
|
||||||
|
#
|
||||||
|
# NOTE the layout: the dash sits alone and the keys are indented under
|
||||||
|
# it. This is the form used verbatim in PHPStan's own documentation.
|
||||||
|
# (The more compact `- message: ...` / continuation form is also valid
|
||||||
|
# NEON, but NOT every NEON parser in the wild handles it — the PHP
|
||||||
|
# parser PHPStan uses handles both, Python's neon-py handles neither
|
||||||
|
# reliably. Staying with the documented form avoids the argument.)
|
||||||
|
-
|
||||||
|
message: '#Call to an undefined method Symfony\\Component\\HttpFoundation\\Session\\SessionInterface::#'
|
||||||
|
reportUnmatched: false
|
||||||
|
|
||||||
|
includes:
|
||||||
|
# Ships EMPTY with this config. A repo overwrites it when it runs
|
||||||
|
# --generate-baseline. It must exist: a missing `includes` target is a hard
|
||||||
|
# error, not a silent skip, which is why the empty file is committed rather
|
||||||
|
# than the include being made conditional.
|
||||||
|
- phpstan-baseline.neon
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# OPTIONAL EXTENSIONS — commented out on purpose.
|
||||||
|
#
|
||||||
|
# These keys are owned by PHPStan *extensions*, not core. If the extension is
|
||||||
|
# not installed, or the key name drifts between extension majors, analysis
|
||||||
|
# fails outright. So they are opt-in per project rather than shared.
|
||||||
|
#
|
||||||
|
# Symfony — resolves service ids, autowiring, and container params from the
|
||||||
|
# compiled container. Requires phpstan/phpstan-symfony. Uncomment AND make sure
|
||||||
|
# the path exists (warm the dev cache first, or let the test bootstrap do it).
|
||||||
|
#
|
||||||
|
# symfony:
|
||||||
|
# containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
|
||||||
|
#
|
||||||
|
# Doctrine — validates DQL against the actual mapping. Requires
|
||||||
|
# phpstan/phpstan-doctrine. `repositoryClass` must name a class that EXISTS in
|
||||||
|
# the project; setting it to a class you do not have is an instant failure.
|
||||||
|
#
|
||||||
|
# doctrine:
|
||||||
|
# repositoryClass: App\Repository\YourBaseRepository
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
+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
|
||||||
|
|
||||||
@@ -236,6 +248,14 @@ 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.
|
||||||
|
|
||||||
### Cache
|
### Cache
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-19
@@ -26,31 +26,31 @@ final readonly class ConfigBag
|
|||||||
|
|
||||||
/** @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,
|
||||||
) {
|
) {
|
||||||
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,17 +60,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-20
@@ -14,59 +14,61 @@ final class Payload
|
|||||||
public string $id; /* session name, identifying who is logging in */
|
public string $id; /* session name, identifying who is logging in */
|
||||||
public string $token; /* TOTP, typically six digits */
|
public string $token; /* TOTP, typically six digits */
|
||||||
public string $nonce; /* random unique string, to block duplicate submissions */
|
public string $nonce; /* random unique string, to block duplicate submissions */
|
||||||
public bool $json; /* should we return json (for the login page) */
|
public bool $json; /* should we return json (for the login page) */
|
||||||
public Scope $scope; /* type of access being requested */
|
public Scope $scope; /* type of access being requested */
|
||||||
|
|
||||||
public static function decode(string $base64url): ?Payload
|
public static function decode(string $base64url): ?self
|
||||||
{
|
{
|
||||||
/* convert the base64url into json string */
|
/* convert the base64url into json string */
|
||||||
$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,
|
'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->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 +76,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ 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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,29 +39,29 @@ 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(),
|
||||||
]);
|
]);
|
||||||
$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 +75,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,28 +43,28 @@ 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,
|
||||||
) {
|
) {
|
||||||
$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 +80,7 @@ final readonly class LoginListener
|
|||||||
/* token or backup-code authentication was successful */
|
/* token or backup-code authentication was successful */
|
||||||
if ($response) {
|
if ($response) {
|
||||||
$event->setResponse($response);
|
$event->setResponse($response);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,14 +93,15 @@ 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 ?? ''),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 */
|
||||||
@@ -111,24 +113,24 @@ 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,
|
||||||
];
|
];
|
||||||
|
|
||||||
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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
|
|
||||||
|
use App\Service\DomainInterface;
|
||||||
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\HttpKernel\Event\ResponseEvent;
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
@@ -15,15 +16,20 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
|||||||
*/
|
*/
|
||||||
final readonly class SecurityHeadersListener
|
final readonly class SecurityHeadersListener
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DomainInterface $domainManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
#[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');
|
||||||
@@ -36,13 +42,46 @@ final readonly class SecurityHeadersListener
|
|||||||
|
|
||||||
/* Content-Security-Policy — the login page uses inline styles
|
/* Content-Security-Policy — the login page uses inline styles
|
||||||
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
||||||
* for those. No external resources are loaded. */
|
* for those. No external resources are loaded.
|
||||||
$headers->set(
|
*
|
||||||
'Content-Security-Policy',
|
* When subdomain redirection is off (or the request is not on
|
||||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
|
* the auth subdomain), the login form is served inline on the
|
||||||
);
|
* protected host and submission is performed via a same-origin
|
||||||
|
* fetch() call in _script.html.twig. That fetch is blocked by
|
||||||
|
* the default 'none' policy, so we add connect-src 'self' only
|
||||||
|
* in that case — the least privilege needed to make the form
|
||||||
|
* work. On the auth subdomain the form POSTs normally and no
|
||||||
|
* inline script is included, so the stricter policy applies. */
|
||||||
|
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
|
||||||
|
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
|
||||||
|
|
||||||
|
if ($inlineScript) {
|
||||||
|
$csp .= " connect-src 'self';";
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers->set('Content-Security-Policy', $csp);
|
||||||
|
|
||||||
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
/* 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. */
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
|
||||||
|
$headers->set('Pragma', 'no-cache');
|
||||||
|
$headers->set('Expires', '0');
|
||||||
|
$headers->set('Surrogate-Control', 'no-store');
|
||||||
|
$headers->set('Vary', '*');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Trait\MakeNonceTrait;
|
|||||||
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\HttpFoundation\Cookie;
|
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;
|
||||||
@@ -30,9 +31,9 @@ final readonly class LoginManager implements LoginInterface
|
|||||||
|
|
||||||
/** @throws InvalidArgumentException */
|
/** @throws InvalidArgumentException */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
CacheItemPoolInterface $sessionCache,
|
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||||
private BackupCodeInterface $backupCodeManager,
|
private BackupCodeInterface $backupCodeManager,
|
||||||
private DomainInterface $domainManager,
|
private DomainInterface $domainManager,
|
||||||
) {
|
) {
|
||||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||||
}
|
}
|
||||||
@@ -41,13 +42,13 @@ final readonly class LoginManager implements LoginInterface
|
|||||||
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) */
|
/* token is correct (TOTP or Backup) */
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ final readonly class LoginManager implements LoginInterface
|
|||||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||||
/* mark nonce as spent */
|
/* mark nonce as spent */
|
||||||
$nonceItem->set(false); /* invalid */
|
$nonceItem->set(false); /* invalid */
|
||||||
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */
|
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
|
||||||
$this->nonceCache->save($nonceItem);
|
$this->nonceCache->save($nonceItem);
|
||||||
|
|
||||||
/* token authentication successful, grant access and set response */
|
/* token authentication successful, grant access and set response */
|
||||||
@@ -65,27 +66,27 @@ final readonly class LoginManager implements LoginInterface
|
|||||||
/* if they just want this one page, return ok, to grant them access */
|
/* if they just want this one page, return ok, to grant them access */
|
||||||
$response = $this->authSuccessResponse($cleanId, $this->config);
|
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||||
|
|
||||||
if ($payload->scope !== Scope::None) {
|
if (Scope::None !== $payload->scope) {
|
||||||
/* grant access based on the requested scope */
|
/* grant access based on the requested scope */
|
||||||
if ($payload->scope === Scope::Cookie) {
|
if (Scope::Cookie === $payload->scope) {
|
||||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||||
} elseif ($payload->scope === Scope::Ip) {
|
} elseif (Scope::Ip === $payload->scope) {
|
||||||
$this->setIp($cleanId, $request->getClientIp());
|
$this->setIp($cleanId, $request->getClientIp());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($payload->json) {
|
if ($payload->json) {
|
||||||
$contentType = 'application/json';
|
$contentType = 'application/json';
|
||||||
$content = json_encode([
|
$content = json_encode([
|
||||||
'message' => 'Login successful',
|
'message' => 'Login successful',
|
||||||
'nonce' => null,
|
'nonce' => null,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$contentType = 'text/html';
|
$contentType = 'text/html';
|
||||||
$content = "hi $cleanId, please reload";
|
$content = "hi $cleanId, please reload";
|
||||||
}
|
}
|
||||||
|
|
||||||
$location = $request->query->has('return') &&
|
$location = $request->query->has('return')
|
||||||
$this->domainManager->validReturn($request->query->get('return')) ?
|
&& $this->domainManager->validReturn($request->query->get('return')) ?
|
||||||
"{$request->query->get('return')}" :
|
"{$request->query->get('return')}" :
|
||||||
"{$request->getPathInfo()}{$request->getQueryString()}";
|
"{$request->getPathInfo()}{$request->getQueryString()}";
|
||||||
|
|
||||||
@@ -97,9 +98,11 @@ final readonly class LoginManager implements LoginInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->debug("successful login for: $cleanId");
|
$this->logger->debug("successful login for: $cleanId");
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +112,11 @@ final readonly class LoginManager implements LoginInterface
|
|||||||
/* successful auth with token, store session and set the cookie */
|
/* successful auth with token, store session and set the cookie */
|
||||||
$ulid = new Ulid();
|
$ulid = new Ulid();
|
||||||
$sessionCookie = $this->sessionCache->getItem(
|
$sessionCookie = $this->sessionCache->getItem(
|
||||||
$this->makeCacheKey("cookie_$ulid")
|
$this->makeCacheKey("cookie_$ulid"),
|
||||||
);
|
);
|
||||||
if ($sessionCookie->isHit()) {
|
if ($sessionCookie->isHit()) {
|
||||||
/* it is supposed to be impossible to have collisions */
|
/* it is supposed to be impossible to have collisions */
|
||||||
$this->logger->error("aborting: ULID collision");
|
$this->logger->error('aborting: ULID collision');
|
||||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||||
}
|
}
|
||||||
$sessionCookie->set($id);
|
$sessionCookie->set($id);
|
||||||
|
|||||||
@@ -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.'$#';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Support;
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
use App\ConfigBag;
|
use DateTimeImmutable;
|
||||||
use App\Service\DomainManager;
|
use Symfony\Component\RateLimiter\LimiterInterface;
|
||||||
use Psr\Log\NullLogger;
|
|
||||||
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 +22,27 @@ 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,
|
'debug' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $twig;
|
return $twig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +53,12 @@ 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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(?string $key = null): LimiterInterface
|
public function create(?string $key = null): LimiterInterface
|
||||||
{
|
{
|
||||||
return $this->limiter;
|
return $this->limiter;
|
||||||
@@ -70,22 +70,26 @@ 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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function consume(int $tokens = 1): RateLimit
|
public function consume(int $tokens = 1): RateLimit
|
||||||
{
|
{
|
||||||
return $this->rateLimit;
|
return $this->rateLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -98,35 +102,42 @@ 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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(?string $key = null): LimiterInterface
|
public function create(?string $key = null): LimiterInterface
|
||||||
{
|
{
|
||||||
return $this->limiter;
|
return $this->limiter;
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ 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 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 +29,12 @@ 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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function now(): DateTimeImmutable
|
public function now(): DateTimeImmutable
|
||||||
{
|
{
|
||||||
return new DateTimeImmutable($this->time);
|
return new DateTimeImmutable($this->time);
|
||||||
@@ -47,6 +47,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ trait TotpTestHelper
|
|||||||
): 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,
|
||||||
@@ -107,6 +109,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,7 +28,7 @@ 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'] as $poolId) {
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Unit;
|
namespace App\Tests\Unit;
|
||||||
|
|
||||||
use App\Clock;
|
use App\Clock;
|
||||||
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
final class ClockTest extends TestCase
|
final class ClockTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testNowReturnsDateTimeImmutable(): void
|
public function test_now_returns_date_time_immutable(): void
|
||||||
{
|
{
|
||||||
$clock = new Clock();
|
$clock = new Clock();
|
||||||
$before = new \DateTimeImmutable();
|
$before = new DateTimeImmutable();
|
||||||
$now = $clock->now();
|
$now = $clock->now();
|
||||||
$after = new \DateTimeImmutable();
|
$after = new DateTimeImmutable();
|
||||||
|
|
||||||
self::assertInstanceOf(\DateTimeImmutable::class, $now);
|
self::assertInstanceOf(DateTimeImmutable::class, $now);
|
||||||
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
|
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
|
||||||
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
|
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Unit\Command;
|
namespace App\Tests\Unit\Command;
|
||||||
|
|
||||||
use App\Command\GenerateBackupCodesCommand;
|
use App\Command\GenerateBackupCodesCommand;
|
||||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
|
||||||
use App\PersistCache;
|
use App\PersistCache;
|
||||||
use App\Service\BackupCodeInterface;
|
use App\Service\BackupCodeInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
final class GenerateBackupCodesCommandTest extends TestCase
|
final class GenerateBackupCodesCommandTest extends TestCase
|
||||||
@@ -25,16 +25,17 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
|||||||
{
|
{
|
||||||
$manager = $this->createStub(BackupCodeInterface::class);
|
$manager = $this->createStub(BackupCodeInterface::class);
|
||||||
$manager->method('generate')->willReturn($generatedCodes);
|
$manager->method('generate')->willReturn($generatedCodes);
|
||||||
|
|
||||||
return $manager;
|
return $manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateDefaultCountOutputsCodes(): void
|
public function test_generate_default_count_outputs_codes(): void
|
||||||
{
|
{
|
||||||
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
|
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
|
||||||
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
|
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
|
||||||
$command = new GenerateBackupCodesCommand(
|
$command = new GenerateBackupCodesCommand(
|
||||||
$this->makeManagerStub($codes),
|
$this->makeManagerStub($codes),
|
||||||
$this->makePersistCache()
|
$this->makePersistCache(),
|
||||||
);
|
);
|
||||||
$command->setName('app:generate-backup-codes');
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateSpecificCountPassesCountToManager(): void
|
public function test_generate_specific_count_passes_count_to_manager(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createMock(BackupCodeInterface::class);
|
$manager = $this->createMock(BackupCodeInterface::class);
|
||||||
$manager->expects(self::once())
|
$manager->expects(self::once())
|
||||||
@@ -65,7 +66,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
|||||||
self::assertSame(0, $exit);
|
self::assertSame(0, $exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDefaultCountArgumentIsTen(): void
|
public function test_default_count_argument_is_ten(): void
|
||||||
{
|
{
|
||||||
// the configured default for the count argument should be 10
|
// the configured default for the count argument should be 10
|
||||||
$manager = $this->createMock(BackupCodeInterface::class);
|
$manager = $this->createMock(BackupCodeInterface::class);
|
||||||
@@ -84,7 +85,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
|||||||
$this->addToAssertionCount(1);
|
$this->addToAssertionCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBootsAndPersistsCache(): void
|
public function test_boots_and_persists_cache(): void
|
||||||
{
|
{
|
||||||
// PersistCache is final and can't be mocked, but we can verify the
|
// PersistCache is final and can't be mocked, but we can verify the
|
||||||
// command runs end-to-end with a real instance; boot()/persist()
|
// command runs end-to-end with a real instance; boot()/persist()
|
||||||
@@ -92,7 +93,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
|||||||
// without throwing.
|
// without throwing.
|
||||||
$command = new GenerateBackupCodesCommand(
|
$command = new GenerateBackupCodesCommand(
|
||||||
$this->makeManagerStub(['code1']),
|
$this->makeManagerStub(['code1']),
|
||||||
$this->makePersistCache()
|
$this->makePersistCache(),
|
||||||
);
|
);
|
||||||
$command->setName('app:generate-backup-codes');
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
@@ -102,25 +103,25 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
|||||||
self::assertSame(0, $exit);
|
self::assertSame(0, $exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testZeroCodesThrowsException(): void
|
public function test_zero_codes_throws_exception(): void
|
||||||
{
|
{
|
||||||
// count must be a positive integer — zero is rejected
|
// count must be a positive integer — zero is rejected
|
||||||
$command = new GenerateBackupCodesCommand(
|
$command = new GenerateBackupCodesCommand(
|
||||||
$this->makeManagerStub([]),
|
$this->makeManagerStub([]),
|
||||||
$this->makePersistCache()
|
$this->makePersistCache(),
|
||||||
);
|
);
|
||||||
$command->setName('app:generate-backup-codes');
|
$command->setName('app:generate-backup-codes');
|
||||||
|
|
||||||
$tester = new CommandTester($command);
|
$tester = new CommandTester($command);
|
||||||
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
$tester->execute(['count' => 0]);
|
$tester->execute(['count' => 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCommandNameAndDescriptionAreConfigured(): void
|
public function test_command_name_and_description_are_configured(): void
|
||||||
{
|
{
|
||||||
$command = new GenerateBackupCodesCommand(
|
$command = new GenerateBackupCodesCommand(
|
||||||
$this->makeManagerStub(['dummy']),
|
$this->makeManagerStub(['dummy']),
|
||||||
$this->makePersistCache()
|
$this->makePersistCache(),
|
||||||
);
|
);
|
||||||
// configuring via the Application runs the protected configure()
|
// configuring via the Application runs the protected configure()
|
||||||
$app = new \Symfony\Component\Console\Application();
|
$app = new \Symfony\Component\Console\Application();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Unit;
|
namespace App\Tests\Unit;
|
||||||
|
|
||||||
use App\ConfigBag;
|
|
||||||
use App\Enum\RemoteUserMode;
|
use App\Enum\RemoteUserMode;
|
||||||
use App\Tests\Support\TotpTestHelper;
|
use App\Tests\Support\TotpTestHelper;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -13,14 +12,14 @@ final class ConfigBagRemoteUserTest extends TestCase
|
|||||||
{
|
{
|
||||||
use TotpTestHelper;
|
use TotpTestHelper;
|
||||||
|
|
||||||
public function testDefaultRemoteUserModeIsSession(): void
|
public function test_default_remote_user_mode_is_session(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig();
|
$config = $this->makeConfig();
|
||||||
|
|
||||||
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testStaticMode(): void
|
public function test_static_mode(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ final class ConfigBagRemoteUserTest extends TestCase
|
|||||||
self::assertSame('authenticated', $config->remoteUserStatic());
|
self::assertSame('authenticated', $config->remoteUserStatic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMappedMode(): void
|
public function test_mapped_mode(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
|
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
|
||||||
|
|
||||||
@@ -36,28 +35,28 @@ final class ConfigBagRemoteUserTest extends TestCase
|
|||||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNoneMode(): void
|
public function test_none_mode(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'none');
|
$config = $this->makeConfig(remoteUserMode: 'none');
|
||||||
|
|
||||||
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
|
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInvalidModeFallsBackToSession(): void
|
public function test_invalid_mode_falls_back_to_session(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
|
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
|
||||||
|
|
||||||
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEmptyMapReturnsEmptyArray(): void
|
public function test_empty_map_returns_empty_array(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
|
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
|
||||||
|
|
||||||
self::assertSame([], $config->remoteUserMap());
|
self::assertSame([], $config->remoteUserMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMapParsesWithWhitespace(): void
|
public function test_map_parses_with_whitespace(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(
|
$config = $this->makeConfig(
|
||||||
remoteUserMode: 'mapped',
|
remoteUserMode: 'mapped',
|
||||||
@@ -67,7 +66,7 @@ final class ConfigBagRemoteUserTest extends TestCase
|
|||||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMapIgnoresInvalidEntries(): void
|
public function test_map_ignores_invalid_entries(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(
|
$config = $this->makeConfig(
|
||||||
remoteUserMode: 'mapped',
|
remoteUserMode: 'mapped',
|
||||||
@@ -77,7 +76,7 @@ final class ConfigBagRemoteUserTest extends TestCase
|
|||||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMapPreservesColonsInValue(): void
|
public function test_map_preserves_colons_in_value(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(
|
$config = $this->makeConfig(
|
||||||
remoteUserMode: 'mapped',
|
remoteUserMode: 'mapped',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ final class ConfigBagTest extends TestCase
|
|||||||
$clock = $this->createStub(ClockInterface::class);
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
$cache = $this->createStub(CacheItemPoolInterface::class);
|
$cache = $this->createStub(CacheItemPoolInterface::class);
|
||||||
|
|
||||||
if ($totp !== null) {
|
if (null !== $totp) {
|
||||||
$item = $this->createStub(CacheItemInterface::class);
|
$item = $this->createStub(CacheItemInterface::class);
|
||||||
$item->method('isHit')->willReturn(true);
|
$item->method('isHit')->willReturn(true);
|
||||||
$item->method('get')->willReturn($totp);
|
$item->method('get')->willReturn($totp);
|
||||||
@@ -31,7 +31,7 @@ final class ConfigBagTest extends TestCase
|
|||||||
return new Utilities($clock, $cache);
|
return new Utilities($clock, $cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGettersWithExplicitValues(): void
|
public function test_getters_with_explicit_values(): void
|
||||||
{
|
{
|
||||||
$clock = $this->createStub(ClockInterface::class);
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
$utilities = $this->createUtilities();
|
$utilities = $this->createUtilities();
|
||||||
@@ -61,7 +61,7 @@ final class ConfigBagTest extends TestCase
|
|||||||
self::assertSame('Too Many!', $config->tooManyTitle());
|
self::assertSame('Too Many!', $config->tooManyTitle());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void
|
public function test_totp_uri_falls_back_to_utilities_when_empty(): void
|
||||||
{
|
{
|
||||||
$clock = $this->createStub(ClockInterface::class);
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
$utilities = $this->createUtilities('fallback-totp');
|
$utilities = $this->createUtilities('fallback-totp');
|
||||||
@@ -84,7 +84,7 @@ final class ConfigBagTest extends TestCase
|
|||||||
self::assertSame('fallback-totp', $config->totpUri());
|
self::assertSame('fallback-totp', $config->totpUri());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIpTtlFallsBackToNullWhenZero(): void
|
public function test_ip_ttl_falls_back_to_null_when_zero(): void
|
||||||
{
|
{
|
||||||
$clock = $this->createStub(ClockInterface::class);
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
$utilities = $this->createUtilities();
|
$utilities = $this->createUtilities();
|
||||||
@@ -107,7 +107,7 @@ final class ConfigBagTest extends TestCase
|
|||||||
self::assertNull($config->ipTtl());
|
self::assertNull($config->ipTtl());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIpTtlFallsBackToNullWhenNull(): void
|
public function test_ip_ttl_falls_back_to_null_when_null(): void
|
||||||
{
|
{
|
||||||
$clock = $this->createStub(ClockInterface::class);
|
$clock = $this->createStub(ClockInterface::class);
|
||||||
$utilities = $this->createUtilities();
|
$utilities = $this->createUtilities();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class PayloadTest extends TestCase
|
|||||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeValidBase64Url(): void
|
public function test_decode_valid_base64_url(): void
|
||||||
{
|
{
|
||||||
$data = json_encode([
|
$data = json_encode([
|
||||||
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
|
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
|
||||||
@@ -32,49 +32,49 @@ final class PayloadTest extends TestCase
|
|||||||
self::assertSame(Scope::Cookie, $payload->scope);
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeInvalidBase64UrlReturnsNull(): void
|
public function test_decode_invalid_base64_url_returns_null(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
|
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeNonObjectJsonReturnsNull(): void
|
public function test_decode_non_object_json_returns_null(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Payload::decode(self::b64u('"just a string"')));
|
self::assertNull(Payload::decode(self::b64u('"just a string"')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeInvalidJsonReturnsNull(): void
|
public function test_decode_invalid_json_returns_null(): void
|
||||||
{
|
{
|
||||||
// valid base64url but invalid JSON
|
// valid base64url but invalid JSON
|
||||||
self::assertNull(Payload::decode(self::b64u('{invalid json')));
|
self::assertNull(Payload::decode(self::b64u('{invalid json')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeJsonArrayReturnsNull(): void
|
public function test_decode_json_array_returns_null(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
|
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeJsonNullReturnsNull(): void
|
public function test_decode_json_null_returns_null(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Payload::decode(self::b64u('null')));
|
self::assertNull(Payload::decode(self::b64u('null')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeJsonBooleanReturnsNull(): void
|
public function test_decode_json_boolean_returns_null(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Payload::decode(self::b64u('true')));
|
self::assertNull(Payload::decode(self::b64u('true')));
|
||||||
self::assertNull(Payload::decode(self::b64u('false')));
|
self::assertNull(Payload::decode(self::b64u('false')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeJsonNumberReturnsNull(): void
|
public function test_decode_json_number_returns_null(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Payload::decode(self::b64u('42')));
|
self::assertNull(Payload::decode(self::b64u('42')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDecodeEmptyStringReturnsNull(): void
|
public function test_decode_empty_string_returns_null(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Payload::decode(''));
|
self::assertNull(Payload::decode(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadWithValidInputBag(): void
|
public function test_load_with_valid_input_bag(): void
|
||||||
{
|
{
|
||||||
$input = new InputBag([
|
$input = new InputBag([
|
||||||
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
|
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
|
||||||
@@ -89,34 +89,34 @@ final class PayloadTest extends TestCase
|
|||||||
self::assertSame(Scope::Cookie, $payload->scope);
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadMissingUsernameReturnsNull(): void
|
public function test_load_missing_username_returns_null(): void
|
||||||
{
|
{
|
||||||
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
|
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
|
||||||
self::assertNull(Payload::load($input));
|
self::assertNull(Payload::load($input));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadMissingNonceReturnsNull(): void
|
public function test_load_missing_nonce_returns_null(): void
|
||||||
{
|
{
|
||||||
$input = new InputBag(['username' => 'u', 'totp' => 't']);
|
$input = new InputBag(['username' => 'u', 'totp' => 't']);
|
||||||
self::assertNull(Payload::load($input));
|
self::assertNull(Payload::load($input));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadMissingTotpReturnsNull(): void
|
public function test_load_missing_totp_returns_null(): void
|
||||||
{
|
{
|
||||||
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
|
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
|
||||||
self::assertNull(Payload::load($input));
|
self::assertNull(Payload::load($input));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void
|
public function test_load_with_all_fields_present_but_empty_returns_null(): void
|
||||||
{
|
{
|
||||||
// has() returns true for all, but create() rejects empty values
|
// has() returns true for all, but create() rejects empty values
|
||||||
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
|
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
|
||||||
self::assertNull(Payload::load($input));
|
self::assertNull(Payload::load($input));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithValidData(): void
|
public function test_create_with_valid_data(): void
|
||||||
{
|
{
|
||||||
$data = (object)[
|
$data = (object) [
|
||||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||||
'json' => false, 'scope' => 'ip',
|
'json' => false, 'scope' => 'ip',
|
||||||
];
|
];
|
||||||
@@ -130,16 +130,16 @@ final class PayloadTest extends TestCase
|
|||||||
self::assertSame(Scope::Ip, $payload->scope);
|
self::assertSame(Scope::Ip, $payload->scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithDefaultScope(): void
|
public function test_create_with_default_scope(): void
|
||||||
{
|
{
|
||||||
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
$data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||||
$payload = Payload::create($data);
|
$payload = Payload::create($data);
|
||||||
self::assertSame(Scope::Cookie, $payload->scope);
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithInvalidScopeFallsBackToCookie(): void
|
public function test_create_with_invalid_scope_falls_back_to_cookie(): void
|
||||||
{
|
{
|
||||||
$data = (object)[
|
$data = (object) [
|
||||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||||
'scope' => 'admin',
|
'scope' => 'admin',
|
||||||
];
|
];
|
||||||
@@ -147,16 +147,16 @@ final class PayloadTest extends TestCase
|
|||||||
self::assertSame(Scope::Cookie, $payload->scope);
|
self::assertSame(Scope::Cookie, $payload->scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithMissingJsonDefaultsToTrue(): void
|
public function test_create_with_missing_json_defaults_to_true(): void
|
||||||
{
|
{
|
||||||
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
$data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||||
$payload = Payload::create($data);
|
$payload = Payload::create($data);
|
||||||
self::assertTrue($payload->json);
|
self::assertTrue($payload->json);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithNoneScopeSetsJsonFalse(): void
|
public function test_create_with_none_scope_sets_json_false(): void
|
||||||
{
|
{
|
||||||
$data = (object)[
|
$data = (object) [
|
||||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||||
'json' => true, 'scope' => 'none',
|
'json' => true, 'scope' => 'none',
|
||||||
];
|
];
|
||||||
@@ -165,37 +165,37 @@ final class PayloadTest extends TestCase
|
|||||||
self::assertFalse($payload->json);
|
self::assertFalse($payload->json);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithEmptyIdReturnsNull(): void
|
public function test_create_with_empty_id_returns_null(): void
|
||||||
{
|
{
|
||||||
$data = (object)['id' => '', 'token' => 't', 'nonce' => 'n'];
|
$data = (object) ['id' => '', 'token' => 't', 'nonce' => 'n'];
|
||||||
self::assertNull(Payload::create($data));
|
self::assertNull(Payload::create($data));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithWhitespaceIdReturnsNull(): void
|
public function test_create_with_whitespace_id_returns_null(): void
|
||||||
{
|
{
|
||||||
$data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n'];
|
$data = (object) ['id' => ' ', 'token' => 't', 'nonce' => 'n'];
|
||||||
self::assertNull(Payload::create($data));
|
self::assertNull(Payload::create($data));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithEmptyTokenReturnsNull(): void
|
public function test_create_with_empty_token_returns_null(): void
|
||||||
{
|
{
|
||||||
$data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n'];
|
$data = (object) ['id' => 'u', 'token' => '', 'nonce' => 'n'];
|
||||||
self::assertNull(Payload::create($data));
|
self::assertNull(Payload::create($data));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateWithEmptyNonceReturnsNull(): void
|
public function test_create_with_empty_nonce_returns_null(): void
|
||||||
{
|
{
|
||||||
$data = (object)['id' => 'u', 'token' => 't', 'nonce' => ''];
|
$data = (object) ['id' => 'u', 'token' => 't', 'nonce' => ''];
|
||||||
self::assertNull(Payload::create($data));
|
self::assertNull(Payload::create($data));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateTrimsAndTruncatesFields(): void
|
public function test_create_trims_and_truncates_fields(): void
|
||||||
{
|
{
|
||||||
$long = str_repeat('a', 200);
|
$long = str_repeat('a', 200);
|
||||||
$data = (object)[
|
$data = (object) [
|
||||||
'id' => ' ' . $long . ' ',
|
'id' => ' '.$long.' ',
|
||||||
'token' => ' ' . $long . ' ',
|
'token' => ' '.$long.' ',
|
||||||
'nonce' => ' ' . $long . ' ',
|
'nonce' => ' '.$long.' ',
|
||||||
];
|
];
|
||||||
$payload = Payload::create($data);
|
$payload = Payload::create($data);
|
||||||
$expected = mb_substr($long, 0, 128);
|
$expected = mb_substr($long, 0, 128);
|
||||||
@@ -204,7 +204,7 @@ final class PayloadTest extends TestCase
|
|||||||
self::assertSame($expected, $payload->nonce);
|
self::assertSame($expected, $payload->nonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testToString(): void
|
public function test_to_string(): void
|
||||||
{
|
{
|
||||||
$payload = new Payload();
|
$payload = new Payload();
|
||||||
$payload->id = 'u';
|
$payload->id = 'u';
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ use PHPUnit\Framework\TestCase;
|
|||||||
|
|
||||||
final class ScopeTest extends TestCase
|
final class ScopeTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testCases(): void
|
public function test_cases(): void
|
||||||
{
|
{
|
||||||
self::assertSame('cookie', Scope::Cookie->value);
|
self::assertSame('cookie', Scope::Cookie->value);
|
||||||
self::assertSame('ip', Scope::Ip->value);
|
self::assertSame('ip', Scope::Ip->value);
|
||||||
self::assertSame('none', Scope::None->value);
|
self::assertSame('none', Scope::None->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTryFromValid(): void
|
public function test_try_from_valid(): void
|
||||||
{
|
{
|
||||||
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
|
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
|
||||||
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
|
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
|
||||||
self::assertSame(Scope::None, Scope::tryFrom('none'));
|
self::assertSame(Scope::None, Scope::tryFrom('none'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTryFromInvalid(): void
|
public function test_try_from_invalid(): void
|
||||||
{
|
{
|
||||||
self::assertNull(Scope::tryFrom('invalid'));
|
self::assertNull(Scope::tryFrom('invalid'));
|
||||||
self::assertNull(Scope::tryFrom(''));
|
self::assertNull(Scope::tryFrom(''));
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
@@ -30,13 +29,14 @@ final class AcceptListenerTest extends TestCase
|
|||||||
): AcceptListener {
|
): AcceptListener {
|
||||||
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
|
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
|
||||||
$listener->setLogger(new NullLogger());
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
return $listener;
|
return $listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function makeEvent(Request $request): RequestEvent
|
private function makeEvent(Request $request): RequestEvent
|
||||||
{
|
{
|
||||||
return new RequestEvent(
|
return new RequestEvent(
|
||||||
$this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class),
|
$this->createStub(HttpKernelInterface::class),
|
||||||
$request,
|
$request,
|
||||||
HttpKernelInterface::MAIN_REQUEST,
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
);
|
);
|
||||||
@@ -44,11 +44,11 @@ final class AcceptListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── valid cookie session ─────────────────────────────────────────── */
|
/* ── valid cookie session ─────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testValidCookieSetsResponseWithRemoteUser(): void
|
public function test_valid_cookie_sets_response_with_remote_user(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
$item = $pool->getItem('cookie_' . $ulid);
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
$item->set('alice');
|
$item->set('alice');
|
||||||
$pool->save($item);
|
$pool->save($item);
|
||||||
|
|
||||||
@@ -68,11 +68,11 @@ final class AcceptListenerTest extends TestCase
|
|||||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void
|
public function test_valid_cookie_uses_auth_cookie_name_when_using_central_auth(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
$item = $pool->getItem('cookie_' . $ulid);
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
$item->set('bob');
|
$item->set('bob');
|
||||||
$pool->save($item);
|
$pool->save($item);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ final class AcceptListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── negative cases ───────────────────────────────────────────────── */
|
/* ── negative cases ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testNoCookieSetsNoResponse(): void
|
public function test_no_cookie_sets_no_response(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
@@ -103,7 +103,7 @@ final class AcceptListenerTest extends TestCase
|
|||||||
self::assertFalse($event->hasResponse());
|
self::assertFalse($event->hasResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCookieWithoutSessionSetsNoResponse(): void
|
public function test_cookie_without_session_sets_no_response(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
@@ -118,7 +118,7 @@ final class AcceptListenerTest extends TestCase
|
|||||||
self::assertFalse($event->hasResponse());
|
self::assertFalse($event->hasResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEmptyCookieValueSetsNoResponse(): void
|
public function test_empty_cookie_value_sets_no_response(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
@@ -137,11 +137,11 @@ final class AcceptListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── Remote-User header modes ─────────────────────────────────────── */
|
/* ── Remote-User header modes ─────────────────────────────────────── */
|
||||||
|
|
||||||
public function testRemoteUserSessionModeSendsSessionId(): void
|
public function test_remote_user_session_mode_sends_session_id(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
$item = $pool->getItem('cookie_' . $ulid);
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
$item->set('alice');
|
$item->set('alice');
|
||||||
$pool->save($item);
|
$pool->save($item);
|
||||||
|
|
||||||
@@ -162,11 +162,11 @@ final class AcceptListenerTest extends TestCase
|
|||||||
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
|
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRemoteUserStaticModeSendsFixedValue(): void
|
public function test_remote_user_static_mode_sends_fixed_value(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
$item = $pool->getItem('cookie_' . $ulid);
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
$item->set('alice');
|
$item->set('alice');
|
||||||
$pool->save($item);
|
$pool->save($item);
|
||||||
|
|
||||||
@@ -187,11 +187,11 @@ final class AcceptListenerTest extends TestCase
|
|||||||
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
|
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRemoteUserMappedModeSendsMappedValue(): void
|
public function test_remote_user_mapped_mode_sends_mapped_value(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
$item = $pool->getItem('cookie_' . $ulid);
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
$item->set('alice');
|
$item->set('alice');
|
||||||
$pool->save($item);
|
$pool->save($item);
|
||||||
|
|
||||||
@@ -212,11 +212,11 @@ final class AcceptListenerTest extends TestCase
|
|||||||
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
|
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRemoteUserMappedModeFallsBackToSessionIdWhenNotInMap(): void
|
public function test_remote_user_mapped_mode_falls_back_to_session_id_when_not_in_map(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
$item = $pool->getItem('cookie_' . $ulid);
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
$item->set('unknown_user');
|
$item->set('unknown_user');
|
||||||
$pool->save($item);
|
$pool->save($item);
|
||||||
|
|
||||||
@@ -237,11 +237,11 @@ final class AcceptListenerTest extends TestCase
|
|||||||
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
|
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRemoteUserNoneModeOmitsHeader(): void
|
public function test_remote_user_none_mode_omits_header(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||||
$item = $pool->getItem('cookie_' . $ulid);
|
$item = $pool->getItem('cookie_'.$ulid);
|
||||||
$item->set('alice');
|
$item->set('alice');
|
||||||
$pool->save($item);
|
$pool->save($item);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final class AllowListenerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$listener = new AllowListener($pool, $config);
|
$listener = new AllowListener($pool, $config);
|
||||||
$listener->setLogger(new NullLogger());
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
return $listener;
|
return $listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ final class AllowListenerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidIpSessionSetsResponseWithRemoteUser(): void
|
public function test_valid_ip_session_sets_response_with_remote_user(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$item = $pool->getItem('ip_1.2.3.4');
|
$item = $pool->getItem('ip_1.2.3.4');
|
||||||
@@ -55,7 +56,7 @@ final class AllowListenerTest extends TestCase
|
|||||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNoIpSessionSetsNoResponse(): void
|
public function test_no_ip_session_sets_no_response(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$config = $this->makeConfig(ipTtl: 1800);
|
$config = $this->makeConfig(ipTtl: 1800);
|
||||||
@@ -68,7 +69,7 @@ final class AllowListenerTest extends TestCase
|
|||||||
self::assertFalse($event->hasResponse());
|
self::assertFalse($event->hasResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIpAccessDisabledSetsNoResponse(): void
|
public function test_ip_access_disabled_sets_no_response(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
// even though there's a stored session, ip access is disabled
|
// even though there's a stored session, ip access is disabled
|
||||||
@@ -86,7 +87,7 @@ final class AllowListenerTest extends TestCase
|
|||||||
self::assertFalse($event->hasResponse());
|
self::assertFalse($event->hasResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIpAccessDisabledDoesNotCheckCache(): void
|
public function test_ip_access_disabled_does_not_check_cache(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$config = $this->makeConfig(ipTtl: 0);
|
$config = $this->makeConfig(ipTtl: 0);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
);
|
);
|
||||||
$listener->setLogger(new NullLogger());
|
$listener->setLogger(new NullLogger());
|
||||||
$listener->setNonceCache($nonceCache ?? new ArrayAdapter());
|
$listener->setNonceCache($nonceCache ?? new ArrayAdapter());
|
||||||
|
|
||||||
return $listener;
|
return $listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── central-auth redirect branch ─────────────────────────────────── */
|
/* ── central-auth redirect branch ─────────────────────────────────── */
|
||||||
|
|
||||||
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void
|
public function test_redirects_to_auth_subdomain_when_host_matches_base_domain(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -68,7 +69,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
|
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void
|
public function test_does_not_redirect_when_already_on_auth_subdomain(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -86,7 +87,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── login page rendering branch ──────────────────────────────────── */
|
/* ── login page rendering branch ──────────────────────────────────── */
|
||||||
|
|
||||||
public function testPresentsLoginPageWithUnauthorizedStatus(): void
|
public function test_presents_login_page_with_unauthorized_status(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -105,7 +106,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
self::assertStringContainsString('name="nonce"', $content);
|
self::assertStringContainsString('name="nonce"', $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGeneratedNonceIsStoredInCache(): void
|
public function test_generated_nonce_is_stored_in_cache(): void
|
||||||
{
|
{
|
||||||
$nonceCache = new ArrayAdapter();
|
$nonceCache = new ArrayAdapter();
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
@@ -123,10 +124,10 @@ final class InterceptListenerTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ArrayAdapter stores raw values; verify at least one item was saved
|
// ArrayAdapter stores raw values; verify at least one item was saved
|
||||||
self::assertTrue(count($nonceCache->getValues()) > 0);
|
self::assertTrue(\count($nonceCache->getValues()) > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void
|
public function test_login_template_uses_post_form_when_on_auth_subdomain(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -140,7 +141,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
self::assertStringContainsString('method="post"', $content);
|
self::assertStringContainsString('method="post"', $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void
|
public function test_login_template_does_not_use_post_form_when_not_on_auth_subdomain(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -156,7 +157,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── invalid cookie pruning ───────────────────────────────────────── */
|
/* ── invalid cookie pruning ───────────────────────────────────────── */
|
||||||
|
|
||||||
public function testInvalidCookieIsClearedWhenPresent(): void
|
public function test_invalid_cookie_is_cleared_when_present(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -174,14 +175,14 @@ final class InterceptListenerTest extends TestCase
|
|||||||
$cookies = $response->headers->getCookies();
|
$cookies = $response->headers->getCookies();
|
||||||
$cleared = false;
|
$cleared = false;
|
||||||
foreach ($cookies as $cookie) {
|
foreach ($cookies as $cookie) {
|
||||||
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
|
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
|
||||||
$cleared = true;
|
$cleared = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
|
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNoCookieClearingWhenNoCookiePresent(): void
|
public function test_no_cookie_clearing_when_no_cookie_present(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(false, '');
|
$domainManager = new DomainManager(false, '');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -194,7 +195,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
self::assertSame([], $response->headers->getCookies());
|
self::assertSame([], $response->headers->getCookies());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void
|
public function test_invalid_cookie_uses_auth_cookie_name_with_central_auth(): void
|
||||||
{
|
{
|
||||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
$listener = $this->makeListener($domainManager);
|
$listener = $this->makeListener($domainManager);
|
||||||
@@ -209,7 +210,7 @@ final class InterceptListenerTest extends TestCase
|
|||||||
$response = $event->getResponse();
|
$response = $event->getResponse();
|
||||||
$cleared = false;
|
$cleared = false;
|
||||||
foreach ($response->headers->getCookies() as $cookie) {
|
foreach ($response->headers->getCookies() as $cookie) {
|
||||||
if ($cookie->getName() === self::AUTH_COOKIE_NAME && $cookie->isCleared()) {
|
if (self::AUTH_COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
|
||||||
$cleared = true;
|
$cleared = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Unit\Listener;
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
use App\Data\Payload;
|
use App\Data\Payload;
|
||||||
use App\Enum\Scope;
|
|
||||||
use App\Listener\LoginListener;
|
use App\Listener\LoginListener;
|
||||||
use App\Service\DomainManager;
|
use App\Service\DomainManager;
|
||||||
use App\Service\LoginInterface;
|
use App\Service\LoginInterface;
|
||||||
@@ -38,6 +37,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
);
|
);
|
||||||
$listener->setLogger(new NullLogger());
|
$listener->setLogger(new NullLogger());
|
||||||
$listener->setNonceCache(new ArrayAdapter());
|
$listener->setNonceCache(new ArrayAdapter());
|
||||||
|
|
||||||
return $listener;
|
return $listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +53,14 @@ final class LoginListenerTest extends TestCase
|
|||||||
/** Build a base64url-encoded X-Preauth header value for a payload. */
|
/** Build a base64url-encoded X-Preauth header value for a payload. */
|
||||||
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), '+/', '-_'), '=');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── no login attempt ─────────────────────────────────────────────── */
|
/* ── no login attempt ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void
|
public function test_no_header_and_no_post_returns_early_without_response(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener();
|
$listener = $this->makeListener();
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
self::assertFalse($event->hasResponse());
|
self::assertFalse($event->hasResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void
|
public function test_post_to_non_auth_subdomain_returns_early_without_response(): void
|
||||||
{
|
{
|
||||||
// POST only counts as a login attempt when on the auth subdomain
|
// POST only counts as a login attempt when on the auth subdomain
|
||||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||||
@@ -85,7 +86,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── successful login via header ──────────────────────────────────── */
|
/* ── successful login via header ──────────────────────────────────── */
|
||||||
|
|
||||||
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void
|
public function test_successful_login_via_header_sets_response_from_manager(): void
|
||||||
{
|
{
|
||||||
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
|
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
|
||||||
$loginManager = $this->createStub(LoginInterface::class);
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
@@ -106,7 +107,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
self::assertSame($expected, $event->getResponse());
|
self::assertSame($expected, $event->getResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulLoginViaPostToAuthSubdomain(): void
|
public function test_successful_login_via_post_to_auth_subdomain(): void
|
||||||
{
|
{
|
||||||
$expected = new Response('hi bob', 303, ['Location' => '/']);
|
$expected = new Response('hi bob', 303, ['Location' => '/']);
|
||||||
$loginManager = $this->createStub(LoginInterface::class);
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
@@ -128,7 +129,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── failed login ─────────────────────────────────────────────────── */
|
/* ── failed login ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void
|
public function test_failed_login_returns_json_error_with_new_nonce(): void
|
||||||
{
|
{
|
||||||
$loginManager = $this->createStub(LoginInterface::class);
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
$loginManager->method('checkToken')->willReturn(null);
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
@@ -157,7 +158,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
self::assertSame('alice', $body['username']);
|
self::assertSame('alice', $body['username']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFailedLoginHtmlResponseWhenJsonFalse(): void
|
public function test_failed_login_html_response_when_json_false(): void
|
||||||
{
|
{
|
||||||
$loginManager = $this->createStub(LoginInterface::class);
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
$loginManager->method('checkToken')->willReturn(null);
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
@@ -179,7 +180,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
self::assertStringContainsString('<form', $response->getContent());
|
self::assertStringContainsString('<form', $response->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void
|
public function test_failed_login_on_auth_subdomain_uses_post_form(): void
|
||||||
{
|
{
|
||||||
$loginManager = $this->createStub(LoginInterface::class);
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
$loginManager->method('checkToken')->willReturn(null);
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
@@ -205,7 +206,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── rate-limited (blocked) login ─────────────────────────────────── */
|
/* ── rate-limited (blocked) login ─────────────────────────────────── */
|
||||||
|
|
||||||
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void
|
public function test_rate_limited_login_returns_teapot_when_teapot_enabled(): void
|
||||||
{
|
{
|
||||||
$loginManager = $this->createStub(LoginInterface::class);
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
$loginManager->method('checkToken')->willReturn(null);
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
@@ -232,7 +233,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
self::assertSame('Teapot', $body['message']);
|
self::assertSame('Teapot', $body['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void
|
public function test_rate_limited_login_returns_too_many_requests_when_teapot_disabled(): void
|
||||||
{
|
{
|
||||||
$loginManager = $this->createStub(LoginInterface::class);
|
$loginManager = $this->createStub(LoginInterface::class);
|
||||||
$loginManager->method('checkToken')->willReturn(null);
|
$loginManager->method('checkToken')->willReturn(null);
|
||||||
@@ -265,7 +266,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── invalid payload handling ─────────────────────────────────────── */
|
/* ── invalid payload handling ─────────────────────────────────────── */
|
||||||
|
|
||||||
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void
|
public function test_invalid_header_payload_still_records_failure_and_responds(): void
|
||||||
{
|
{
|
||||||
$loginManager = $this->createMock(LoginInterface::class);
|
$loginManager = $this->createMock(LoginInterface::class);
|
||||||
// checkToken should not be called with a null payload
|
// checkToken should not be called with a null payload
|
||||||
@@ -286,7 +287,7 @@ final class LoginListenerTest extends TestCase
|
|||||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
|
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void
|
public function test_post_without_required_fields_does_not_attempt_login(): void
|
||||||
{
|
{
|
||||||
$loginManager = $this->createMock(LoginInterface::class);
|
$loginManager = $this->createMock(LoginInterface::class);
|
||||||
$loginManager->expects(self::never())->method('checkToken');
|
$loginManager->expects(self::never())->method('checkToken');
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
$this->makeRateLimiterFactory($remainingTokens),
|
$this->makeRateLimiterFactory($remainingTokens),
|
||||||
);
|
);
|
||||||
$listener->setLogger(new NullLogger());
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
return $listener;
|
return $listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── feature disabled ──────────────────────────────────────────────── */
|
/* ── feature disabled ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testNoPublicPathsReturnsWithoutResponse(): void
|
public function test_no_public_paths_returns_without_response(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '');
|
$listener = $this->makeListener(publicPaths: '');
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── non-public path ───────────────────────────────────────────────── */
|
/* ── non-public path ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testNonPublicPathReturnsWithoutResponse(): void
|
public function test_non_public_path_returns_without_response(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '/public/**');
|
$listener = $this->makeListener(publicPaths: '/public/**');
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── public path within rate limit ─────────────────────────────────── */
|
/* ── public path within rate limit ─────────────────────────────────── */
|
||||||
|
|
||||||
public function testPublicPathWithinRateLimitReturns200(): void
|
public function test_public_path_within_rate_limit_returns200(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── public path rate limited ──────────────────────────────────────── */
|
/* ── public path rate limited ──────────────────────────────────────── */
|
||||||
|
|
||||||
public function testPublicPathOverRateLimitReturns429(): void
|
public function test_public_path_over_rate_limit_returns429(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
self::assertTrue($response->headers->has('Retry-After'));
|
self::assertTrue($response->headers->has('Retry-After'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRateLimitedResponseContainsErrorTemplate(): void
|
public function test_rate_limited_response_contains_error_template(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── auth subdomain is never public ────────────────────────────────── */
|
/* ── auth subdomain is never public ────────────────────────────────── */
|
||||||
|
|
||||||
public function testAuthSubdomainRequestIsSkipped(): void
|
public function test_auth_subdomain_request_is_skipped(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(
|
$listener = $this->makeListener(
|
||||||
publicPaths: '/**',
|
publicPaths: '/**',
|
||||||
@@ -147,7 +148,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── query string is ignored ───────────────────────────────────────── */
|
/* ── query string is ignored ───────────────────────────────────────── */
|
||||||
|
|
||||||
public function testQueryStringIsIgnoredForPathMatching(): void
|
public function test_query_string_is_ignored_for_path_matching(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
|
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── domain-scoped paths ───────────────────────────────────────────── */
|
/* ── domain-scoped paths ───────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testDomainScopedPathMatchesCorrectHost(): void
|
public function test_domain_scoped_path_matches_correct_host(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDomainScopedPathDoesNotMatchOtherHost(): void
|
public function test_domain_scoped_path_does_not_match_other_host(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
@@ -186,7 +187,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── wildcard matching ─────────────────────────────────────────────── */
|
/* ── wildcard matching ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testSingleWildcardMatching(): void
|
public function test_single_wildcard_matching(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSingleWildcardDoesNotMatchDeepPath(): void
|
public function test_single_wildcard_does_not_match_deep_path(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||||
|
|
||||||
@@ -211,7 +212,7 @@ final class PublicAccessListenerTest extends TestCase
|
|||||||
|
|
||||||
/* ── 200 response includes remaining token count ───────────────────── */
|
/* ── 200 response includes remaining token count ───────────────────── */
|
||||||
|
|
||||||
public function testOkResponseIncludesRetryAfterHeader(): void
|
public function test_ok_response_includes_retry_after_header(): void
|
||||||
{
|
{
|
||||||
// The 200 response includes a Retry-After header showing remaining tokens
|
// The 200 response includes a Retry-After header showing remaining tokens
|
||||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Unit\Listener;
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
use App\Listener\RejectListener;
|
use App\Listener\RejectListener;
|
||||||
use App\Service\DomainManager;
|
|
||||||
use App\Tests\Support\ListenerTestHelper;
|
use App\Tests\Support\ListenerTestHelper;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
@@ -28,6 +27,7 @@ final class RejectListenerTest extends TestCase
|
|||||||
$this->makeRateLimiterFactory($remainingTokens),
|
$this->makeRateLimiterFactory($remainingTokens),
|
||||||
);
|
);
|
||||||
$listener->setLogger(new NullLogger());
|
$listener->setLogger(new NullLogger());
|
||||||
|
|
||||||
return $listener;
|
return $listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ final class RejectListenerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void
|
public function test_blocked_request_returns_teapot_when_teapot_enabled(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ final class RejectListenerTest extends TestCase
|
|||||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void
|
public function test_blocked_request_returns_too_many_requests_when_teapot_disabled(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(teapot: false, remainingTokens: 0);
|
$listener = $this->makeListener(teapot: false, remainingTokens: 0);
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ final class RejectListenerTest extends TestCase
|
|||||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnblockedRequestSetsNoResponse(): void
|
public function test_unblocked_request_sets_no_response(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(remainingTokens: 5);
|
$listener = $this->makeListener(remainingTokens: 5);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ final class RejectListenerTest extends TestCase
|
|||||||
self::assertFalse($event->hasResponse());
|
self::assertFalse($event->hasResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBlockedResponseContainsErrorTemplateContent(): void
|
public function test_blocked_response_contains_error_template_content(): void
|
||||||
{
|
{
|
||||||
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\Listener\SecurityHeadersListener;
|
||||||
|
use App\Service\DomainInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
final class SecurityHeadersListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
private function makeListener(?string $authSubdomain = null): SecurityHeadersListener
|
||||||
|
{
|
||||||
|
$domainManager = $this->createStub(DomainInterface::class);
|
||||||
|
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
|
||||||
|
|
||||||
|
return new SecurityHeadersListener($domainManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(
|
||||||
|
Response $response,
|
||||||
|
?Request $request = null,
|
||||||
|
int $requestType = HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
): ResponseEvent {
|
||||||
|
return new ResponseEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request ?? Request::create('https://example.com/', 'GET'),
|
||||||
|
$requestType,
|
||||||
|
$response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The emitted Cache-Control is normalized by Symfony (directives are
|
||||||
|
* reordered), so assert on directives rather than the exact string.
|
||||||
|
*/
|
||||||
|
private function assertNoStoreHeaders(Response $response): void
|
||||||
|
{
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('proxy-revalidate'));
|
||||||
|
self::assertSame('0', $response->headers->getCacheControlDirective('max-age'));
|
||||||
|
self::assertSame('0', $response->headers->getCacheControlDirective('s-maxage'));
|
||||||
|
self::assertSame('no-cache', $response->headers->get('Pragma'));
|
||||||
|
self::assertSame('0', $response->headers->get('Expires'));
|
||||||
|
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
|
||||||
|
self::assertSame('*', $response->headers->get('Vary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertNoAntiCachingHeaders(Response $response): void
|
||||||
|
{
|
||||||
|
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertNull($response->headers->get('Pragma'));
|
||||||
|
self::assertNull($response->headers->get('Expires'));
|
||||||
|
self::assertNull($response->headers->get('Surrogate-Control'));
|
||||||
|
self::assertNull($response->headers->get('Vary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── non-2xx: the login flow must not be cacheable ────────────────── */
|
||||||
|
|
||||||
|
public function test_login_page_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_redirect_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('', Response::HTTP_SEE_OTHER, [
|
||||||
|
'Location' => 'https://example.com/dashboard',
|
||||||
|
]);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
// the redirect target must survive
|
||||||
|
self::assertSame('https://example.com/dashboard', $response->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limited_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('<h1>teapot</h1>', Response::HTTP_I_AM_A_TEAPOT);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_server_error_response_is_not_cacheable(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('error', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
$this->assertNoStoreHeaders($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 2xx: authenticated / public grants stay untouched ────────────── */
|
||||||
|
|
||||||
|
public function test_successful_authenticated_response_is_not_touched(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('hi alice', Response::HTTP_OK, [
|
||||||
|
'Remote-User' => 'alice',
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
]);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
// "already authenticated" responses are consumed by the reverse
|
||||||
|
// proxy's forward_auth check and never reach the browser, so they
|
||||||
|
// must not carry the anti-caching headers (or they could leak onto
|
||||||
|
// the protected service's own responses in custom configurations)
|
||||||
|
$this->assertNoAntiCachingHeaders($response);
|
||||||
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_successful_response_keeps_its_own_cache_headers(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('ok', Response::HTTP_OK, [
|
||||||
|
'Cache-Control' => 'public, max-age=60',
|
||||||
|
]);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
// the service's caching decisions are its own business;
|
||||||
|
// Symfony normalizes directive order, so assert semantically
|
||||||
|
self::assertTrue($response->headers->hasCacheControlDirective('public'));
|
||||||
|
self::assertSame('60', $response->headers->getCacheControlDirective('max-age'));
|
||||||
|
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── sub-requests ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_sub_requests_are_skipped(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response, null, HttpKernelInterface::SUB_REQUEST);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
|
||||||
|
self::assertNull($response->headers->get('X-Frame-Options'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the pre-existing security headers ────────────────────────────── */
|
||||||
|
|
||||||
|
public function test_security_headers_are_applied(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener();
|
||||||
|
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
|
||||||
|
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
|
||||||
|
self::assertSame('strict-origin-when-cross-origin', $response->headers->get('Referrer-Policy'));
|
||||||
|
self::assertSame('max-age=31536000', $response->headers->get('Strict-Transport-Security'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_csp_allows_same_origin_connect_when_inline_script_is_used(): void
|
||||||
|
{
|
||||||
|
// not on the auth subdomain: the login form uses an inline fetch()
|
||||||
|
$listener = $this->makeListener('auth.example.com');
|
||||||
|
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$event = $this->makeEvent($response);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertStringContainsString("connect-src 'self';", $response->headers->get('Content-Security-Policy'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_csp_does_not_allow_connect_when_on_auth_subdomain(): void
|
||||||
|
{
|
||||||
|
// on the auth subdomain the form POSTs normally — no inline fetch
|
||||||
|
$listener = $this->makeListener('auth.example.com');
|
||||||
|
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||||
|
$request = Request::create('https://auth.example.com/', 'GET');
|
||||||
|
$event = $this->makeEvent($response, $request);
|
||||||
|
|
||||||
|
$listener->onKernelResponse($event);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('connect-src', $response->headers->get('Content-Security-Policy'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,11 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
|
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
|
||||||
{
|
{
|
||||||
$pool ??= new ArrayAdapter();
|
$pool ??= new ArrayAdapter();
|
||||||
|
|
||||||
return new MonitorCacheKeys($pool);
|
return new MonitorCacheKeys($pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testConstructorInitializesEmptyPool(): void
|
public function test_constructor_initializes_empty_pool(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame([], $monitor->getChanges());
|
self::assertSame([], $monitor->getChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSaveAddsKeyAndTracksChange(): void
|
public function test_save_adds_key_and_tracks_change(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('alpha');
|
$item = $monitor->getItem('alpha');
|
||||||
@@ -36,7 +37,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSaveDeferredThenCommitAddsKey(): void
|
public function test_save_deferred_then_commit_adds_key(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('beta');
|
$item = $monitor->getItem('beta');
|
||||||
@@ -48,7 +49,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetItemReturnsUnderlyingItem(): void
|
public function test_get_item_returns_underlying_item(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('mykey');
|
$item = $monitor->getItem('mykey');
|
||||||
@@ -60,7 +61,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame('data', $fetched->get());
|
self::assertSame('data', $fetched->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetItemsReturnsMultipleItems(): void
|
public function test_get_items_returns_multiple_items(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$a = $monitor->getItem('a');
|
$a = $monitor->getItem('a');
|
||||||
@@ -78,7 +79,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame(['a' => 1, 'b' => 2], $keys);
|
self::assertSame(['a' => 1, 'b' => 2], $keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHasItemReturnsTrueForExistingKey(): void
|
public function test_has_item_returns_true_for_existing_key(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('exists');
|
$item = $monitor->getItem('exists');
|
||||||
@@ -89,7 +90,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertFalse($monitor->hasItem('missing'));
|
self::assertFalse($monitor->hasItem('missing'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemRemovesKeyAndTracksRemoval(): void
|
public function test_delete_item_removes_key_and_tracks_removal(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('doomed');
|
$item = $monitor->getItem('doomed');
|
||||||
@@ -103,7 +104,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertFalse($monitor->hasItem('doomed'));
|
self::assertFalse($monitor->hasItem('doomed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemOnMissingKeyIsNoop(): void
|
public function test_delete_item_on_missing_key_is_noop(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame([], $monitor->getKeys());
|
self::assertSame([], $monitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemsRemovesMultipleKeys(): void
|
public function test_delete_items_removes_multiple_keys(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
foreach (['x', 'y', 'z'] as $key) {
|
foreach (['x', 'y', 'z'] as $key) {
|
||||||
@@ -130,7 +131,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
|
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void
|
public function test_delete_items_with_missing_keys_still_returns_true(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertTrue($result);
|
self::assertTrue($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testClearWipesPoolWhenNotEmpty(): void
|
public function test_clear_wipes_pool_when_not_empty(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('keep');
|
$item = $monitor->getItem('keep');
|
||||||
@@ -152,7 +153,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame([], $monitor->getKeys());
|
self::assertSame([], $monitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testClearIsNoopWhenEmpty(): void
|
public function test_clear_is_noop_when_empty(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertTrue($result);
|
self::assertTrue($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMarkCleanResetsChangeList(): void
|
public function test_mark_clean_resets_change_list(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('temp');
|
$item = $monitor->getItem('temp');
|
||||||
@@ -176,14 +177,14 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame(['temp'], $monitor->getKeys());
|
self::assertSame(['temp'], $monitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCommitPassesThrough(): void
|
public function test_commit_passes_through(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
self::assertTrue($monitor->commit());
|
self::assertTrue($monitor->commit());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSaveKeyListThrowsOutOfBoundsException(): void
|
public function test_save_key_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('__key_list');
|
$item = $monitor->getItem('__key_list');
|
||||||
@@ -192,7 +193,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->save($item);
|
$monitor->save($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSaveChangeListThrowsOutOfBoundsException(): void
|
public function test_save_change_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('__chg_list');
|
$item = $monitor->getItem('__chg_list');
|
||||||
@@ -201,7 +202,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->save($item);
|
$monitor->save($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteKeyListThrowsOutOfBoundsException(): void
|
public function test_delete_key_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->deleteItem('__key_list');
|
$monitor->deleteItem('__key_list');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteChangeListThrowsOutOfBoundsException(): void
|
public function test_delete_change_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->deleteItem('__chg_list');
|
$monitor->deleteItem('__chg_list');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void
|
public function test_delete_items_with_key_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -225,7 +226,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->deleteItems(['safe', '__key_list']);
|
$monitor->deleteItems(['safe', '__key_list']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void
|
public function test_delete_items_with_change_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
|
|
||||||
@@ -233,7 +234,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->deleteItems(['__chg_list']);
|
$monitor->deleteItems(['__chg_list']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void
|
public function test_save_deferred_on_key_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('safe');
|
$item = $monitor->getItem('safe');
|
||||||
@@ -247,7 +248,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->saveDeferred($keyListItem);
|
$monitor->saveDeferred($keyListItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void
|
public function test_save_deferred_on_change_list_throws_out_of_bounds_exception(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$changeListItem = $monitor->getItem('__chg_list');
|
$changeListItem = $monitor->getItem('__chg_list');
|
||||||
@@ -256,7 +257,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
$monitor->saveDeferred($changeListItem);
|
$monitor->saveDeferred($changeListItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void
|
public function test_get_keys_returns_empty_array_when_key_list_missing(): void
|
||||||
{
|
{
|
||||||
// If the underlying pool loses its key list, getKeys should return []
|
// If the underlying pool loses its key list, getKeys should return []
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
@@ -275,7 +276,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertSame([], $monitor2->getKeys());
|
self::assertSame([], $monitor2->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemReturnsTrueForExistingKey(): void
|
public function test_delete_item_returns_true_for_existing_key(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
$item = $monitor->getItem('to-delete');
|
$item = $monitor->getItem('to-delete');
|
||||||
@@ -286,7 +287,7 @@ final class MonitorCacheKeysTest extends TestCase
|
|||||||
self::assertNotContains('to-delete', $monitor->getKeys());
|
self::assertNotContains('to-delete', $monitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteItemsReturnsTrue(): void
|
public function test_delete_items_returns_true(): void
|
||||||
{
|
{
|
||||||
$monitor = $this->wrap();
|
$monitor = $this->wrap();
|
||||||
foreach (['a', 'b', 'c'] as $key) {
|
foreach (['a', 'b', 'c'] as $key) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
|||||||
|
|
||||||
final class PersistCacheTest extends TestCase
|
final class PersistCacheTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testBootWithEmptyStorageIsNoop(): void
|
public function test_boot_with_empty_storage_is_noop(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -24,7 +24,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertSame([], $monitor->getKeys());
|
self::assertSame([], $monitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBootLoadsFromStorageIntoCache(): void
|
public function test_boot_loads_from_storage_into_cache(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -47,7 +47,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertSame([], $cacheMonitor->getChanges());
|
self::assertSame([], $cacheMonitor->getChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void
|
public function test_boot_does_not_reload_when_cache_already_warm(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -73,7 +73,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertNotContains('cookie_new', $monitor->getKeys());
|
self::assertNotContains('cookie_new', $monitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPersistWritesChangesToStorage(): void
|
public function test_persist_writes_changes_to_storage(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -95,7 +95,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
|
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPersistHandlesRemovals(): void
|
public function test_persist_handles_removals(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -121,7 +121,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
|
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPersistIsNoopWhenNoChanges(): void
|
public function test_persist_is_noop_when_no_changes(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -134,7 +134,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertSame([], $storageMonitor->getKeys());
|
self::assertSame([], $storageMonitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFullBootModifyPersistCycle(): void
|
public function test_full_boot_modify_persist_cycle(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -160,7 +160,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
|
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPersistHandlesMixedUpdatesAndRemovals(): void
|
public function test_persist_handles_mixed_updates_and_removals(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
@@ -194,7 +194,7 @@ final class PersistCacheTest extends TestCase
|
|||||||
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
|
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMultipleBootModifyPersistCycles(): void
|
public function test_multiple_boot_modify_persist_cycles(): void
|
||||||
{
|
{
|
||||||
$sessionCache = new ArrayAdapter();
|
$sessionCache = new ArrayAdapter();
|
||||||
$sessionStorage = new ArrayAdapter();
|
$sessionStorage = new ArrayAdapter();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Tests\Unit\Service;
|
|||||||
|
|
||||||
use App\Service\BackupCodeManager;
|
use App\Service\BackupCodeManager;
|
||||||
use App\Tests\Support\TotpTestHelper;
|
use App\Tests\Support\TotpTestHelper;
|
||||||
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
@@ -20,10 +21,11 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
$manager = new BackupCodeManager($pool);
|
$manager = new BackupCodeManager($pool);
|
||||||
$manager->setConfig($this->makeConfig());
|
$manager->setConfig($this->makeConfig());
|
||||||
$manager->setLogger(new NullLogger());
|
$manager->setLogger(new NullLogger());
|
||||||
|
|
||||||
return $manager;
|
return $manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateReturnsRequestedCount(): void
|
public function test_generate_returns_requested_count(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateDefaultCount(): void
|
public function test_generate_default_count(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertCount(10, $codes);
|
self::assertCount(10, $codes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateZeroReturnsEmptyArray(): void
|
public function test_generate_zero_returns_empty_array(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertSame([], $codes);
|
self::assertSame([], $codes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGeneratedCodesAreStoredInCache(): void
|
public function test_generated_codes_are_stored_in_cache(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$manager = $this->makeManager($pool);
|
$manager = $this->makeManager($pool);
|
||||||
@@ -64,15 +66,15 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
|
|
||||||
// each code should be stored as a backup_ key
|
// each code should be stored as a backup_ key
|
||||||
foreach ($codes as $code) {
|
foreach ($codes as $code) {
|
||||||
$key = 'backup_' . strtolower($code);
|
$key = 'backup_'.strtolower($code);
|
||||||
// the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity
|
// the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity
|
||||||
$item = $pool->getItem($key);
|
$item = $pool->getItem($key);
|
||||||
self::assertTrue($item->isHit(), "Expected cache hit for key: $key");
|
self::assertTrue($item->isHit(), "Expected cache hit for key: $key");
|
||||||
self::assertTrue($item->get(), "Expected code to be marked valid (true)");
|
self::assertTrue($item->get(), 'Expected code to be marked valid (true)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGeneratedCodesHaveFarFutureExpiry(): void
|
public function test_generated_codes_have_far_future_expiry(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$manager = $this->makeManager($pool);
|
$manager = $this->makeManager($pool);
|
||||||
@@ -80,12 +82,12 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
$codes = $manager->generate(1);
|
$codes = $manager->generate(1);
|
||||||
$code = $codes[0];
|
$code = $codes[0];
|
||||||
|
|
||||||
$item = $pool->getItem('backup_' . strtolower($code));
|
$item = $pool->getItem('backup_'.strtolower($code));
|
||||||
$expiry = $item->getMetadata()['expiry'];
|
$expiry = $item->getMetadata()['expiry'];
|
||||||
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
|
self::assertGreaterThan((new DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVerifyAndConsumeValidCode(): void
|
public function test_verify_and_consume_valid_code(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
$codes = $manager->generate(2);
|
$codes = $manager->generate(2);
|
||||||
@@ -95,7 +97,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->verifyAndConsume($code));
|
self::assertTrue($manager->verifyAndConsume($code));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVerifyAndConsumeMarksCodeAsUsed(): void
|
public function test_verify_and_consume_marks_code_as_used(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$manager = $this->makeManager($pool);
|
$manager = $this->makeManager($pool);
|
||||||
@@ -109,14 +111,14 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertFalse($manager->verifyAndConsume($code));
|
self::assertFalse($manager->verifyAndConsume($code));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVerifyAndConsumeInvalidCode(): void
|
public function test_verify_and_consume_invalid_code(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
|
|
||||||
self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
|
self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVerifyAndConsumeIsCaseInsensitive(): void
|
public function test_verify_and_consume_is_case_insensitive(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
$codes = $manager->generate(1);
|
$codes = $manager->generate(1);
|
||||||
@@ -126,17 +128,17 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
|
self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVerifyAndConsumeStripsInvalidCharacters(): void
|
public function test_verify_and_consume_strips_invalid_characters(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
$codes = $manager->generate(1);
|
$codes = $manager->generate(1);
|
||||||
$code = $codes[0];
|
$code = $codes[0];
|
||||||
|
|
||||||
// inject spaces and special chars — should be stripped
|
// inject spaces and special chars — should be stripped
|
||||||
self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!'));
|
self::assertTrue($manager->verifyAndConsume(' '.$code.'!!'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExpireRemovesAllBackupCodes(): void
|
public function test_expire_removes_all_backup_codes(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$manager = $this->makeManager($pool);
|
$manager = $this->makeManager($pool);
|
||||||
@@ -146,11 +148,11 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
|
|
||||||
// all backup keys should be gone
|
// all backup keys should be gone
|
||||||
foreach ($codes as $code) {
|
foreach ($codes as $code) {
|
||||||
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
|
self::assertFalse($pool->hasItem('backup_'.strtolower($code)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExpireWhenNoBackupCodesIsNoop(): void
|
public function test_expire_when_no_backup_codes_is_noop(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$manager = $this->makeManager($pool);
|
$manager = $this->makeManager($pool);
|
||||||
@@ -162,7 +164,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertTrue(true);
|
self::assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExpireRemovesOnlyBackupPrefixedKeys(): void
|
public function test_expire_removes_only_backup_prefixed_keys(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$manager = $this->makeManager($pool);
|
$manager = $this->makeManager($pool);
|
||||||
@@ -181,11 +183,11 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
|
|
||||||
// backup keys are gone
|
// backup keys are gone
|
||||||
foreach ($codes as $code) {
|
foreach ($codes as $code) {
|
||||||
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
|
self::assertFalse($pool->hasItem('backup_'.strtolower($code)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void
|
public function test_verify_and_consume_empty_string_returns_false(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertFalse($manager->verifyAndConsume(''));
|
self::assertFalse($manager->verifyAndConsume(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void
|
public function test_verify_and_consume_code_with_value_false_returns_false(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$manager = $this->makeManager($pool);
|
$manager = $this->makeManager($pool);
|
||||||
@@ -204,7 +206,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->verifyAndConsume($code));
|
self::assertTrue($manager->verifyAndConsume($code));
|
||||||
|
|
||||||
// the code is now marked as false (used); isHit is true but get() is false
|
// the code is now marked as false (used); isHit is true but get() is false
|
||||||
$key = 'backup_' . strtolower($code);
|
$key = 'backup_'.strtolower($code);
|
||||||
$item = $pool->getItem($key);
|
$item = $pool->getItem($key);
|
||||||
self::assertTrue($item->isHit());
|
self::assertTrue($item->isHit());
|
||||||
self::assertFalse($item->get());
|
self::assertFalse($item->get());
|
||||||
@@ -213,7 +215,7 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertFalse($manager->verifyAndConsume($code));
|
self::assertFalse($manager->verifyAndConsume($code));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateProducesUniqueCodes(): void
|
public function test_generate_produces_unique_codes(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
|
|
||||||
@@ -223,13 +225,13 @@ final class BackupCodeManagerTest extends TestCase
|
|||||||
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
|
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGenerateCodeLengthIsDigitsPlusTwo(): void
|
public function test_generate_code_length_is_digits_plus_two(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeManager();
|
$manager = $this->makeManager();
|
||||||
|
|
||||||
$codes = $manager->generate(1);
|
$codes = $manager->generate(1);
|
||||||
|
|
||||||
// default TOTP digits is 6, so code length should be 6 + 2 = 8
|
// default TOTP digits is 6, so code length should be 6 + 2 = 8
|
||||||
self::assertSame(8, strlen($codes[0]));
|
self::assertSame(8, \strlen($codes[0]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,42 +16,42 @@ final class DomainManagerTest extends TestCase
|
|||||||
|
|
||||||
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
|
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
|
||||||
|
|
||||||
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void
|
public function test_auth_base_is_null_when_subdomain_redirect_is_disabled(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(false, 'auth.example.com');
|
$manager = $this->createManager(false, 'auth.example.com');
|
||||||
self::assertNull($manager->authBase());
|
self::assertNull($manager->authBase());
|
||||||
self::assertNull($manager->getAuthSubdomain());
|
self::assertNull($manager->getAuthSubdomain());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void
|
public function test_auth_base_is_null_when_auth_subdomain_is_empty(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, '');
|
$manager = $this->createManager(true, '');
|
||||||
self::assertNull($manager->authBase());
|
self::assertNull($manager->authBase());
|
||||||
self::assertNull($manager->getAuthSubdomain());
|
self::assertNull($manager->getAuthSubdomain());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthBaseExtractsSimpleDomain(): void
|
public function test_auth_base_extracts_simple_domain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertSame('example.com', $manager->authBase());
|
self::assertSame('example.com', $manager->authBase());
|
||||||
self::assertSame('auth.example.com', $manager->getAuthSubdomain());
|
self::assertSame('auth.example.com', $manager->getAuthSubdomain());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthBaseExtractsMultiPartTld(): void
|
public function test_auth_base_extracts_multi_part_tld(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||||
self::assertSame('example.co.uk', $manager->authBase());
|
self::assertSame('example.co.uk', $manager->authBase());
|
||||||
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
|
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthBaseIsNullForLocalhostAuth(): void
|
public function test_auth_base_is_null_for_localhost_auth(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'localhost');
|
$manager = $this->createManager(true, 'localhost');
|
||||||
self::assertNull($manager->authBase());
|
self::assertNull($manager->authBase());
|
||||||
self::assertNull($manager->getAuthSubdomain());
|
self::assertNull($manager->getAuthSubdomain());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthBaseIsNullForIpAuth(): void
|
public function test_auth_base_is_null_for_ip_auth(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, '192.168.1.1');
|
$manager = $this->createManager(true, '192.168.1.1');
|
||||||
self::assertNull($manager->authBase());
|
self::assertNull($manager->authBase());
|
||||||
@@ -60,42 +60,42 @@ final class DomainManagerTest extends TestCase
|
|||||||
|
|
||||||
/* ── validReturn ──────────────────────────────────────────────────────── */
|
/* ── validReturn ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void
|
public function test_valid_return_accepts_any_url_when_no_subdomain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(false, '');
|
$manager = $this->createManager(false, '');
|
||||||
self::assertTrue($manager->validReturn('https://evil.com/page'));
|
self::assertTrue($manager->validReturn('https://evil.com/page'));
|
||||||
self::assertTrue($manager->validReturn('https://example.com/ok'));
|
self::assertTrue($manager->validReturn('https://example.com/ok'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnRejectsInvalidUrl(): void
|
public function test_valid_return_rejects_invalid_url(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->validReturn('not-a-url'));
|
self::assertFalse($manager->validReturn('not-a-url'));
|
||||||
self::assertFalse($manager->validReturn(''));
|
self::assertFalse($manager->validReturn(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnAcceptsSameBaseDomain(): void
|
public function test_valid_return_accepts_same_base_domain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
|
self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
|
||||||
self::assertTrue($manager->validReturn('https://example.com/'));
|
self::assertTrue($manager->validReturn('https://example.com/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnRejectsDifferentBaseDomain(): void
|
public function test_valid_return_rejects_different_base_domain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->validReturn('https://evil.com/phish'));
|
self::assertFalse($manager->validReturn('https://evil.com/phish'));
|
||||||
self::assertFalse($manager->validReturn('https://other-example.com/'));
|
self::assertFalse($manager->validReturn('https://other-example.com/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnHandlesCoUkTld(): void
|
public function test_valid_return_handles_co_uk_tld(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||||
self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
|
self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
|
||||||
self::assertFalse($manager->validReturn('https://example.com/'));
|
self::assertFalse($manager->validReturn('https://example.com/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnRejectsUrlWithoutHost(): void
|
public function test_valid_return_rejects_url_without_host(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->validReturn('mailto:test@example.com'));
|
self::assertFalse($manager->validReturn('mailto:test@example.com'));
|
||||||
@@ -103,47 +103,47 @@ final class DomainManagerTest extends TestCase
|
|||||||
|
|
||||||
/* ── matchesAuth ──────────────────────────────────────────────────────── */
|
/* ── matchesAuth ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void
|
public function test_matches_auth_is_false_when_subdomain_redirect_disabled(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(false, 'auth.example.com');
|
$manager = $this->createManager(false, 'auth.example.com');
|
||||||
self::assertFalse($manager->matchesAuth('example.com'));
|
self::assertFalse($manager->matchesAuth('example.com'));
|
||||||
self::assertFalse($manager->matchesAuth('app.example.com'));
|
self::assertFalse($manager->matchesAuth('app.example.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void
|
public function test_matches_auth_is_false_when_auth_subdomain_is_empty(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, '');
|
$manager = $this->createManager(true, '');
|
||||||
self::assertFalse($manager->matchesAuth('example.com'));
|
self::assertFalse($manager->matchesAuth('example.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthMatchesSameBaseDomain(): void
|
public function test_matches_auth_matches_same_base_domain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertTrue($manager->matchesAuth('example.com'));
|
self::assertTrue($manager->matchesAuth('example.com'));
|
||||||
self::assertTrue($manager->matchesAuth('app.example.com'));
|
self::assertTrue($manager->matchesAuth('app.example.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthRejectsDifferentBaseDomain(): void
|
public function test_matches_auth_rejects_different_base_domain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->matchesAuth('evil.com'));
|
self::assertFalse($manager->matchesAuth('evil.com'));
|
||||||
self::assertFalse($manager->matchesAuth('example.org'));
|
self::assertFalse($manager->matchesAuth('example.org'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthHandlesMultiPartTld(): void
|
public function test_matches_auth_handles_multi_part_tld(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||||
self::assertTrue($manager->matchesAuth('www.example.co.uk'));
|
self::assertTrue($manager->matchesAuth('www.example.co.uk'));
|
||||||
self::assertFalse($manager->matchesAuth('example.com'));
|
self::assertFalse($manager->matchesAuth('example.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthRejectsIpHost(): void
|
public function test_matches_auth_rejects_ip_host(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->matchesAuth('192.168.1.1'));
|
self::assertFalse($manager->matchesAuth('192.168.1.1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthRejectsLocalhost(): void
|
public function test_matches_auth_rejects_localhost(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->matchesAuth('localhost'));
|
self::assertFalse($manager->matchesAuth('localhost'));
|
||||||
@@ -151,13 +151,13 @@ final class DomainManagerTest extends TestCase
|
|||||||
|
|
||||||
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
|
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
|
||||||
|
|
||||||
public function testMatchesAuthWithDeepSubdomain(): void
|
public function test_matches_auth_with_deep_subdomain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
|
self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithTwoPartDomain(): void
|
public function test_matches_auth_with_two_part_domain(): void
|
||||||
{
|
{
|
||||||
/* for a 2-part auth subdomain, the baseDomain retains both parts */
|
/* for a 2-part auth subdomain, the baseDomain retains both parts */
|
||||||
$manager = $this->createManager(true, 'auth.local');
|
$manager = $this->createManager(true, 'auth.local');
|
||||||
@@ -169,7 +169,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
|
|
||||||
/* ── TLD table coverage ──────────────────────────────────────────────── */
|
/* ── TLD table coverage ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testMatchesAuthWithComAuTld(): void
|
public function test_matches_auth_with_com_au_tld(): void
|
||||||
{
|
{
|
||||||
// com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part
|
// com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part
|
||||||
$manager = $this->createManager(true, 'auth.example.com.au');
|
$manager = $this->createManager(true, 'auth.example.com.au');
|
||||||
@@ -178,7 +178,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertFalse($manager->matchesAuth('example.com'));
|
self::assertFalse($manager->matchesAuth('example.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithCoJpTld(): void
|
public function test_matches_auth_with_co_jp_tld(): void
|
||||||
{
|
{
|
||||||
// co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part
|
// co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part
|
||||||
$manager = $this->createManager(true, 'auth.example.co.jp');
|
$manager = $this->createManager(true, 'auth.example.co.jp');
|
||||||
@@ -186,7 +186,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
|
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithComBrTld(): void
|
public function test_matches_auth_with_com_br_tld(): void
|
||||||
{
|
{
|
||||||
// com.br: TLD table has br => [com,...], so *.com.br IS multi-part
|
// com.br: TLD table has br => [com,...], so *.com.br IS multi-part
|
||||||
$manager = $this->createManager(true, 'auth.example.com.br');
|
$manager = $this->createManager(true, 'auth.example.com.br');
|
||||||
@@ -194,7 +194,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->matchesAuth('app.example.com.br'));
|
self::assertTrue($manager->matchesAuth('app.example.com.br'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithCoNzTld(): void
|
public function test_matches_auth_with_co_nz_tld(): void
|
||||||
{
|
{
|
||||||
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
|
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
|
||||||
$manager = $this->createManager(true, 'auth.example.co.nz');
|
$manager = $this->createManager(true, 'auth.example.co.nz');
|
||||||
@@ -202,7 +202,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
|
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithComMxTld(): void
|
public function test_matches_auth_with_com_mx_tld(): void
|
||||||
{
|
{
|
||||||
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
|
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
|
||||||
$manager = $this->createManager(true, 'auth.example.com.mx');
|
$manager = $this->createManager(true, 'auth.example.com.mx');
|
||||||
@@ -210,7 +210,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
|
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithCoInTld(): void
|
public function test_matches_auth_with_co_in_tld(): void
|
||||||
{
|
{
|
||||||
// co.in: in => [co,...], so *.co.in IS multi-part
|
// co.in: in => [co,...], so *.co.in IS multi-part
|
||||||
$manager = $this->createManager(true, 'auth.example.co.in');
|
$manager = $this->createManager(true, 'auth.example.co.in');
|
||||||
@@ -218,7 +218,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->matchesAuth('app.example.co.in'));
|
self::assertTrue($manager->matchesAuth('app.example.co.in'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithBrComTld(): void
|
public function test_matches_auth_with_br_com_tld(): void
|
||||||
{
|
{
|
||||||
// br.com: TLD table has com => [br], so *.br.com IS multi-part
|
// br.com: TLD table has com => [br], so *.br.com IS multi-part
|
||||||
$manager = $this->createManager(true, 'auth.example.br.com');
|
$manager = $this->createManager(true, 'auth.example.br.com');
|
||||||
@@ -226,7 +226,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->matchesAuth('app.example.br.com'));
|
self::assertTrue($manager->matchesAuth('app.example.br.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSimpleTldNotTreatedAsMultiPart(): void
|
public function test_simple_tld_not_treated_as_multi_part(): void
|
||||||
{
|
{
|
||||||
// example.com is a standard 2-part domain, not multi-part
|
// example.com is a standard 2-part domain, not multi-part
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
@@ -237,7 +237,7 @@ final class DomainManagerTest extends TestCase
|
|||||||
|
|
||||||
/* ── baseDomain edge cases ───────────────────────────────────────────── */
|
/* ── baseDomain edge cases ───────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testMatchesAuthWithSingleLabelHost(): void
|
public function test_matches_auth_with_single_label_host(): void
|
||||||
{
|
{
|
||||||
// a single-label domain (not localhost, not IP) has baseLength 1
|
// a single-label domain (not localhost, not IP) has baseLength 1
|
||||||
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
|
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
|
||||||
@@ -249,25 +249,25 @@ final class DomainManagerTest extends TestCase
|
|||||||
self::assertTrue($manager->matchesAuth('app.auth.local'));
|
self::assertTrue($manager->matchesAuth('app.auth.local'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMatchesAuthWithEmptyStringHost(): void
|
public function test_matches_auth_with_empty_string_host(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->matchesAuth(''));
|
self::assertFalse($manager->matchesAuth(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnAcceptsUrlWithPort(): void
|
public function test_valid_return_accepts_url_with_port(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
|
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnAcceptsUrlWithoutPath(): void
|
public function test_valid_return_accepts_url_without_path(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertTrue($manager->validReturn('https://example.com'));
|
self::assertTrue($manager->validReturn('https://example.com'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testValidReturnRejectsDifferentDomainWithPort(): void
|
public function test_valid_return_rejects_different_domain_with_port(): void
|
||||||
{
|
{
|
||||||
$manager = $this->createManager(true, 'auth.example.com');
|
$manager = $this->createManager(true, 'auth.example.com');
|
||||||
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
|
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
|
||||||
|
|||||||
@@ -8,21 +8,22 @@ use App\Data\Payload;
|
|||||||
use App\Enum\Scope;
|
use App\Enum\Scope;
|
||||||
use App\Service\BackupCodeInterface;
|
use App\Service\BackupCodeInterface;
|
||||||
use App\Service\DomainManager;
|
use App\Service\DomainManager;
|
||||||
use App\Trait\StringTrait;
|
|
||||||
use App\Service\LoginManager;
|
use App\Service\LoginManager;
|
||||||
use App\Tests\Support\TotpTestHelper;
|
use App\Tests\Support\TotpTestHelper;
|
||||||
|
use App\Trait\StringTrait;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Cache\CacheItemInterface;
|
use Psr\Cache\CacheItemInterface;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
|
use ReflectionProperty;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
final class LoginManagerTest extends TestCase
|
final class LoginManagerTest extends TestCase
|
||||||
{
|
{
|
||||||
use TotpTestHelper;
|
|
||||||
use StringTrait;
|
use StringTrait;
|
||||||
|
use TotpTestHelper;
|
||||||
|
|
||||||
private ArrayAdapter $pool;
|
private ArrayAdapter $pool;
|
||||||
private BackupCodeInterface $backupCodeManager;
|
private BackupCodeInterface $backupCodeManager;
|
||||||
@@ -41,6 +42,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
||||||
$manager->setLogger(new NullLogger());
|
$manager->setLogger(new NullLogger());
|
||||||
$manager->setNonceCache(new ArrayAdapter());
|
$manager->setNonceCache(new ArrayAdapter());
|
||||||
|
|
||||||
return $manager;
|
return $manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +62,14 @@ final class LoginManagerTest extends TestCase
|
|||||||
$payload->nonce = $nonce;
|
$payload->nonce = $nonce;
|
||||||
$payload->json = true;
|
$payload->json = true;
|
||||||
$payload->scope = $scope;
|
$payload->scope = $scope;
|
||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inject a nonce directly into the manager's nonce cache. */
|
/** Inject a nonce directly into the manager's nonce cache. */
|
||||||
private function insertNonce(LoginManager $manager, string $nonce): string
|
private function insertNonce(LoginManager $manager, string $nonce): string
|
||||||
{
|
{
|
||||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
$reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||||
$nonceCache = $reflection->getValue($manager);
|
$nonceCache = $reflection->getValue($manager);
|
||||||
|
|
||||||
$key = $this->makeCacheKey($nonce);
|
$key = $this->makeCacheKey($nonce);
|
||||||
@@ -77,7 +80,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
return $nonce;
|
return $nonce;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCheckTokenReturnsNullForInvalidTotp(): void
|
public function test_check_token_returns_null_for_invalid_totp(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
|
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
|
||||||
@@ -89,7 +92,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertNull($manager->checkToken($payload, $request));
|
self::assertNull($manager->checkToken($payload, $request));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCheckTokenReturnsNullForSpentNonce(): void
|
public function test_check_token_returns_null_for_spent_nonce(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager);
|
$payload = $this->makePayloadWithNonce($manager);
|
||||||
@@ -97,7 +100,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||||
|
|
||||||
// spend the nonce first (use the same cache key the manager does)
|
// spend the nonce first (use the same cache key the manager does)
|
||||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
$reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||||
$nonceCache = $reflection->getValue($manager);
|
$nonceCache = $reflection->getValue($manager);
|
||||||
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
||||||
$nonceItem->set(false);
|
$nonceItem->set(false);
|
||||||
@@ -108,7 +111,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertNull($manager->checkToken($payload, $request));
|
self::assertNull($manager->checkToken($payload, $request));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCheckTokenReturnsNullForMissingNonce(): void
|
public function test_check_token_returns_null_for_missing_nonce(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
|
|
||||||
@@ -126,7 +129,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertNull($manager->checkToken($payload, $request));
|
self::assertNull($manager->checkToken($payload, $request));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void
|
public function test_successful_totp_login_with_cookie_scope_returns_redirect(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||||
@@ -143,7 +146,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertTrue($response->headers->has('Set-Cookie'));
|
self::assertTrue($response->headers->has('Set-Cookie'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void
|
public function test_successful_login_with_none_scope_returns_plain_response(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
|
||||||
@@ -162,7 +165,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertFalse($response->headers->has('Location'));
|
self::assertFalse($response->headers->has('Location'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulLoginSetsRemoteUserHeader(): void
|
public function test_successful_login_sets_remote_user_header(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
|
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
|
||||||
@@ -177,7 +180,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulLoginJsonResponse(): void
|
public function test_successful_login_json_response(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
|
||||||
@@ -195,7 +198,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertSame('Login successful', $body['message']);
|
self::assertSame('Login successful', $body['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulLoginHtmlResponse(): void
|
public function test_successful_login_html_response(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||||
@@ -211,7 +214,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSuccessfulLoginWithReturnUrl(): void
|
public function test_successful_login_with_return_url(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||||
@@ -226,7 +229,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
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
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||||
@@ -242,7 +245,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertStringStartsWith('/login', $location);
|
self::assertStringStartsWith('/login', $location);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void
|
public function test_ip_scope_downgrades_to_cookie_when_ip_access_disabled(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager(ipTtl: 0);
|
$manager = $this->makeLoginManager(ipTtl: 0);
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
||||||
@@ -258,7 +261,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertTrue($response->headers->has('Set-Cookie'));
|
self::assertTrue($response->headers->has('Set-Cookie'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIpScopeWhenEnabledSetsIpSession(): void
|
public function test_ip_scope_when_enabled_sets_ip_session(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager(ipTtl: 1800);
|
$manager = $this->makeLoginManager(ipTtl: 1800);
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
||||||
@@ -274,12 +277,12 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertFalse($response->headers->has('Set-Cookie'));
|
self::assertFalse($response->headers->has('Set-Cookie'));
|
||||||
|
|
||||||
// verify the IP session exists in the cache
|
// verify the IP session exists in the cache
|
||||||
$reflection = new \ReflectionProperty(LoginManager::class, 'sessionCache');
|
$reflection = new ReflectionProperty(LoginManager::class, 'sessionCache');
|
||||||
$sessionCache = $reflection->getValue($manager);
|
$sessionCache = $reflection->getValue($manager);
|
||||||
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
|
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBackupCodeAuthentication(): void
|
public function test_backup_code_authentication(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
|
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
|
||||||
@@ -294,7 +297,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertSame(303, $response->getStatusCode());
|
self::assertSame(303, $response->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNonceIsConsumedAfterSuccessfulLogin(): void
|
public function test_nonce_is_consumed_after_successful_login(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager);
|
$payload = $this->makePayloadWithNonce($manager);
|
||||||
@@ -307,13 +310,13 @@ final class LoginManagerTest extends TestCase
|
|||||||
|
|
||||||
// nonce should now be marked invalid (false); look it up via the same
|
// nonce should now be marked invalid (false); look it up via the same
|
||||||
// cache key the manager uses (makeCacheKey rewrites '-' to '_')
|
// cache key the manager uses (makeCacheKey rewrites '-' to '_')
|
||||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
$reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||||
$nonceCache = $reflection->getValue($manager);
|
$nonceCache = $reflection->getValue($manager);
|
||||||
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
||||||
self::assertFalse($nonceItem->get());
|
self::assertFalse($nonceItem->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUlidCollisionThrowsHttpException(): void
|
public function test_ulid_collision_throws_http_exception(): void
|
||||||
{
|
{
|
||||||
// Use a stub pool where every cookie_ key is already a hit (collision)
|
// Use a stub pool where every cookie_ key is already a hit (collision)
|
||||||
$pool = $this->createStub(CacheItemPoolInterface::class);
|
$pool = $this->createStub(CacheItemPoolInterface::class);
|
||||||
@@ -322,29 +325,32 @@ final class LoginManagerTest extends TestCase
|
|||||||
$item->method('get')->willReturn('existing');
|
$item->method('get')->willReturn('existing');
|
||||||
// The nonce cache needs to work, so we return the stub item for
|
// The nonce cache needs to work, so we return the stub item for
|
||||||
// cookie_ keys but a real working item for nonce keys.
|
// cookie_ keys but a real working item for nonce keys.
|
||||||
$pool->method('getItem')->willReturnCallback(function (string $key) use ($item) {
|
$pool->method('getItem')->willReturnCallback(static function (string $key) use ($item) {
|
||||||
if (str_starts_with($key, 'cookie_')) {
|
if (str_starts_with($key, 'cookie_')) {
|
||||||
return $item; // collision
|
return $item; // collision
|
||||||
}
|
}
|
||||||
// For nonce keys, return a real item from an ArrayAdapter
|
// For nonce keys, return a real item from an ArrayAdapter
|
||||||
static $realPool = null;
|
static $realPool = null;
|
||||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
$realPool ??= new ArrayAdapter();
|
||||||
|
|
||||||
return $realPool->getItem($key);
|
return $realPool->getItem($key);
|
||||||
});
|
});
|
||||||
$pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) {
|
$pool->method('hasItem')->willReturnCallback(static function (string $key) {
|
||||||
if (str_starts_with($key, 'cookie_')) {
|
if (str_starts_with($key, 'cookie_')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
static $realPool = null;
|
static $realPool = null;
|
||||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
$realPool ??= new ArrayAdapter();
|
||||||
|
|
||||||
return $realPool->hasItem($key);
|
return $realPool->hasItem($key);
|
||||||
});
|
});
|
||||||
$pool->method('save')->willReturn(true);
|
$pool->method('save')->willReturn(true);
|
||||||
$pool->method('saveDeferred')->willReturn(true);
|
$pool->method('saveDeferred')->willReturn(true);
|
||||||
$pool->method('commit')->willReturn(true);
|
$pool->method('commit')->willReturn(true);
|
||||||
$pool->method('getItems')->willReturnCallback(function (array $keys) {
|
$pool->method('getItems')->willReturnCallback(static function (array $keys) {
|
||||||
static $realPool = null;
|
static $realPool = null;
|
||||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
$realPool ??= new ArrayAdapter();
|
||||||
|
|
||||||
return $realPool->getItems($keys);
|
return $realPool->getItems($keys);
|
||||||
});
|
});
|
||||||
$pool->method('clear')->willReturn(true);
|
$pool->method('clear')->willReturn(true);
|
||||||
@@ -358,7 +364,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
|
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
|
||||||
$manager->setConfig($this->makeConfig());
|
$manager->setConfig($this->makeConfig());
|
||||||
$manager->setLogger(new NullLogger());
|
$manager->setLogger(new NullLogger());
|
||||||
$manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter());
|
$manager->setNonceCache(new ArrayAdapter());
|
||||||
|
|
||||||
$payload = new Payload();
|
$payload = new Payload();
|
||||||
$payload->id = 'collide-user';
|
$payload->id = 'collide-user';
|
||||||
@@ -376,7 +382,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
$manager->checkToken($payload, $request);
|
$manager->checkToken($payload, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void
|
public function test_cookie_scope_with_central_auth_sets_domain_on_matching_host(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager(
|
$manager = $this->makeLoginManager(
|
||||||
subdomainRedirect: true,
|
subdomainRedirect: true,
|
||||||
@@ -400,7 +406,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void
|
public function test_cookie_scope_with_central_auth_on_non_matching_host_uses_null_domain(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager(
|
$manager = $this->makeLoginManager(
|
||||||
subdomainRedirect: true,
|
subdomainRedirect: true,
|
||||||
@@ -424,7 +430,7 @@ final class LoginManagerTest extends TestCase
|
|||||||
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void
|
public function test_check_token_with_empty_return_parameter_falls_back_to_path(): void
|
||||||
{
|
{
|
||||||
$manager = $this->makeLoginManager();
|
$manager = $this->makeLoginManager();
|
||||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
{
|
{
|
||||||
/* ── empty / disabled ──────────────────────────────────────────────── */
|
/* ── empty / disabled ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testEmptyStringResultsInNoPatterns(): void
|
public function test_empty_string_results_in_no_patterns(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('');
|
$matcher = new PublicPathMatcher('');
|
||||||
self::assertTrue($matcher->isEmpty());
|
self::assertTrue($matcher->isEmpty());
|
||||||
self::assertFalse($matcher->matches('example.com', '/public'));
|
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testWhitespaceOnlyStringResultsInNoPatterns(): void
|
public function test_whitespace_only_string_results_in_no_patterns(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher(' ');
|
$matcher = new PublicPathMatcher(' ');
|
||||||
self::assertTrue($matcher->isEmpty());
|
self::assertTrue($matcher->isEmpty());
|
||||||
@@ -31,20 +31,20 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── exact path matching ───────────────────────────────────────────── */
|
/* ── exact path matching ───────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testExactPathMatch(): void
|
public function test_exact_path_match(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public');
|
$matcher = new PublicPathMatcher('/public');
|
||||||
self::assertTrue($matcher->matches('example.com', '/public'));
|
self::assertTrue($matcher->matches('example.com', '/public'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExactPathDoesNotMatchSubpath(): void
|
public function test_exact_path_does_not_match_subpath(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public');
|
$matcher = new PublicPathMatcher('/public');
|
||||||
self::assertFalse($matcher->matches('example.com', '/public/'));
|
self::assertFalse($matcher->matches('example.com', '/public/'));
|
||||||
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testExactPathDoesNotMatchDifferentPath(): void
|
public function test_exact_path_does_not_match_different_path(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public');
|
$matcher = new PublicPathMatcher('/public');
|
||||||
self::assertFalse($matcher->matches('example.com', '/private'));
|
self::assertFalse($matcher->matches('example.com', '/private'));
|
||||||
@@ -53,26 +53,26 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── single wildcard * ─────────────────────────────────────────────── */
|
/* ── single wildcard * ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testSingleWildcardMatchesOneSegment(): void
|
public function test_single_wildcard_matches_one_segment(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/*');
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||||
self::assertTrue($matcher->matches('example.com', '/public/xyz'));
|
self::assertTrue($matcher->matches('example.com', '/public/xyz'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSingleWildcardDoesNotMatchBasePath(): void
|
public function test_single_wildcard_does_not_match_base_path(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/*');
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
self::assertFalse($matcher->matches('example.com', '/public'));
|
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSingleWildcardDoesNotCrossSegments(): void
|
public function test_single_wildcard_does_not_cross_segments(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/*');
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
self::assertFalse($matcher->matches('example.com', '/public/a/b'));
|
self::assertFalse($matcher->matches('example.com', '/public/a/b'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSingleWildcardDoesNotMatchEmptySegment(): void
|
public function test_single_wildcard_does_not_match_empty_segment(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/*');
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
self::assertFalse($matcher->matches('example.com', '/public/'));
|
self::assertFalse($matcher->matches('example.com', '/public/'));
|
||||||
@@ -80,20 +80,20 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── double wildcard ** ────────────────────────────────────────────── */
|
/* ── double wildcard ** ────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testDoubleWildcardMatchesMultipleSegments(): void
|
public function test_double_wildcard_matches_multiple_segments(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/**');
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
self::assertTrue($matcher->matches('example.com', '/public/a'));
|
self::assertTrue($matcher->matches('example.com', '/public/a'));
|
||||||
self::assertTrue($matcher->matches('example.com', '/public/a/b/c'));
|
self::assertTrue($matcher->matches('example.com', '/public/a/b/c'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDoubleWildcardDoesNotMatchBasePath(): void
|
public function test_double_wildcard_does_not_match_base_path(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/**');
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
self::assertFalse($matcher->matches('example.com', '/public'));
|
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDoubleWildcardMatchesTrailingSlash(): void
|
public function test_double_wildcard_matches_trailing_slash(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/**');
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
self::assertTrue($matcher->matches('example.com', '/public/'));
|
self::assertTrue($matcher->matches('example.com', '/public/'));
|
||||||
@@ -101,7 +101,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── mid-path wildcards ────────────────────────────────────────────── */
|
/* ── mid-path wildcards ────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testMidPathSingleWildcard(): void
|
public function test_mid_path_single_wildcard(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/api/*/status');
|
$matcher = new PublicPathMatcher('/api/*/status');
|
||||||
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
||||||
@@ -110,7 +110,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertFalse($matcher->matches('example.com', '/api/status'));
|
self::assertFalse($matcher->matches('example.com', '/api/status'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMidPathDoubleWildcard(): void
|
public function test_mid_path_double_wildcard(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/api/**/status');
|
$matcher = new PublicPathMatcher('/api/**/status');
|
||||||
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
||||||
@@ -120,7 +120,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── multiple patterns ─────────────────────────────────────────────── */
|
/* ── multiple patterns ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testMultiplePatternsCommaSeparated(): void
|
public function test_multiple_patterns_comma_separated(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/**,/api/status,/health');
|
$matcher = new PublicPathMatcher('/public/**,/api/status,/health');
|
||||||
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||||
@@ -129,7 +129,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertFalse($matcher->matches('example.com', '/private'));
|
self::assertFalse($matcher->matches('example.com', '/private'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMultiplePatternsWithWhitespace(): void
|
public function test_multiple_patterns_with_whitespace(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/**, /api/status, /health');
|
$matcher = new PublicPathMatcher('/public/**, /api/status, /health');
|
||||||
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||||
@@ -137,7 +137,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertTrue($matcher->matches('example.com', '/health'));
|
self::assertTrue($matcher->matches('example.com', '/health'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEmptySegmentsInCommaListAreIgnored(): void
|
public function test_empty_segments_in_comma_list_are_ignored(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public,,/health,');
|
$matcher = new PublicPathMatcher('/public,,/health,');
|
||||||
self::assertFalse($matcher->isEmpty());
|
self::assertFalse($matcher->isEmpty());
|
||||||
@@ -147,20 +147,20 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── domain-prefixed patterns ──────────────────────────────────────── */
|
/* ── domain-prefixed patterns ──────────────────────────────────────── */
|
||||||
|
|
||||||
public function testDomainPrefixedPatternMatchesOnThatHost(): void
|
public function test_domain_prefixed_pattern_matches_on_that_host(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
||||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDomainPrefixedPatternDoesNotMatchOtherHost(): void
|
public function test_domain_prefixed_pattern_does_not_match_other_host(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
||||||
self::assertFalse($matcher->matches('other.example.com', '/public/repo'));
|
self::assertFalse($matcher->matches('other.example.com', '/public/repo'));
|
||||||
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPathWithoutDomainPrefixMatchesAnyHost(): void
|
public function test_path_without_domain_prefix_matches_any_host(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/public/**');
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||||
@@ -168,7 +168,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertTrue($matcher->matches('localhost', '/public/repo'));
|
self::assertTrue($matcher->matches('localhost', '/public/repo'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMixedDomainPrefixedAndPlainPatterns(): void
|
public function test_mixed_domain_prefixed_and_plain_patterns(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/health,code.example.com/public/**');
|
$matcher = new PublicPathMatcher('/health,code.example.com/public/**');
|
||||||
self::assertTrue($matcher->matches('any.host', '/health'));
|
self::assertTrue($matcher->matches('any.host', '/health'));
|
||||||
@@ -176,7 +176,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertFalse($matcher->matches('other.host', '/public/repo'));
|
self::assertFalse($matcher->matches('other.host', '/public/repo'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDomainPrefixedRootPathMatchesRoot(): void
|
public function test_domain_prefixed_root_path_matches_root(): void
|
||||||
{
|
{
|
||||||
// host/ — the trailing slash is the entire path, nothing after it
|
// host/ — the trailing slash is the entire path, nothing after it
|
||||||
$matcher = new PublicPathMatcher('code.example.com/');
|
$matcher = new PublicPathMatcher('code.example.com/');
|
||||||
@@ -185,7 +185,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertFalse($matcher->matches('other.example.com', '/'));
|
self::assertFalse($matcher->matches('other.example.com', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDomainPrefixedRootWithOtherPatterns(): void
|
public function test_domain_prefixed_root_with_other_patterns(): void
|
||||||
{
|
{
|
||||||
// The exact scenario from the bug report
|
// The exact scenario from the bug report
|
||||||
$matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**');
|
$matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**');
|
||||||
@@ -195,7 +195,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertFalse($matcher->matches('other.example.com', '/'));
|
self::assertFalse($matcher->matches('other.example.com', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDomainPrefixIsCaseInsensitive(): void
|
public function test_domain_prefix_is_case_insensitive(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('Code.Example.COM/public/**');
|
$matcher = new PublicPathMatcher('Code.Example.COM/public/**');
|
||||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||||
@@ -204,13 +204,13 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── invalid patterns ──────────────────────────────────────────────── */
|
/* ── invalid patterns ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testPatternWithoutLeadingSlashIsIgnored(): void
|
public function test_pattern_without_leading_slash_is_ignored(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('public');
|
$matcher = new PublicPathMatcher('public');
|
||||||
self::assertTrue($matcher->isEmpty());
|
self::assertTrue($matcher->isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInvalidPatternAmongValidOnesIsIgnored(): void
|
public function test_invalid_pattern_among_valid_ones_is_ignored(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('invalid,/public');
|
$matcher = new PublicPathMatcher('invalid,/public');
|
||||||
self::assertFalse($matcher->isEmpty());
|
self::assertFalse($matcher->isEmpty());
|
||||||
@@ -219,14 +219,14 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── special regex characters in paths ─────────────────────────────── */
|
/* ── special regex characters in paths ─────────────────────────────── */
|
||||||
|
|
||||||
public function testSpecialRegexCharactersAreEscaped(): void
|
public function test_special_regex_characters_are_escaped(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/path.with.dots');
|
$matcher = new PublicPathMatcher('/path.with.dots');
|
||||||
self::assertTrue($matcher->matches('example.com', '/path.with.dots'));
|
self::assertTrue($matcher->matches('example.com', '/path.with.dots'));
|
||||||
self::assertFalse($matcher->matches('example.com', '/pathXwithXdots'));
|
self::assertFalse($matcher->matches('example.com', '/pathXwithXdots'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPlusCharacterIsLiteral(): void
|
public function test_plus_character_is_literal(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/a+b');
|
$matcher = new PublicPathMatcher('/a+b');
|
||||||
self::assertTrue($matcher->matches('example.com', '/a+b'));
|
self::assertTrue($matcher->matches('example.com', '/a+b'));
|
||||||
@@ -235,14 +235,14 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
|
|
||||||
/* ── root path ─────────────────────────────────────────────────────── */
|
/* ── root path ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testRootPathMatch(): void
|
public function test_root_path_match(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/');
|
$matcher = new PublicPathMatcher('/');
|
||||||
self::assertTrue($matcher->matches('example.com', '/'));
|
self::assertTrue($matcher->matches('example.com', '/'));
|
||||||
self::assertFalse($matcher->matches('example.com', '/anything'));
|
self::assertFalse($matcher->matches('example.com', '/anything'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testWildcardAtRoot(): void
|
public function test_wildcard_at_root(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/*');
|
$matcher = new PublicPathMatcher('/*');
|
||||||
self::assertTrue($matcher->matches('example.com', '/anything'));
|
self::assertTrue($matcher->matches('example.com', '/anything'));
|
||||||
@@ -250,7 +250,7 @@ final class PublicPathMatcherTest extends TestCase
|
|||||||
self::assertFalse($matcher->matches('example.com', '/'));
|
self::assertFalse($matcher->matches('example.com', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDoubleWildcardAtRoot(): void
|
public function test_double_wildcard_at_root(): void
|
||||||
{
|
{
|
||||||
$matcher = new PublicPathMatcher('/**');
|
$matcher = new PublicPathMatcher('/**');
|
||||||
self::assertTrue($matcher->matches('example.com', '/'));
|
self::assertTrue($matcher->matches('example.com', '/'));
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ final class CookieNameTraitTest extends TestCase
|
|||||||
{
|
{
|
||||||
use CookieNameTrait;
|
use CookieNameTrait;
|
||||||
|
|
||||||
public function testCookieName(): void
|
public function test_cookie_name(): void
|
||||||
{
|
{
|
||||||
self::assertSame('__Host-Http-Preauth', $this->cookieName());
|
self::assertSame('__Host-Http-Preauth', $this->cookieName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthCookieName(): void
|
public function test_auth_cookie_name(): void
|
||||||
{
|
{
|
||||||
self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
|
self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHeaderName(): void
|
public function test_header_name(): void
|
||||||
{
|
{
|
||||||
self::assertSame('X-Preauth', $this->headerName());
|
self::assertSame('X-Preauth', $this->headerName());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ use App\Tests\Support\TotpTestHelper;
|
|||||||
use App\Trait\GetTotpTrait;
|
use App\Trait\GetTotpTrait;
|
||||||
use OTPHP\TOTPInterface;
|
use OTPHP\TOTPInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionProperty;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
final class GetTotpTraitTest extends TestCase
|
final class GetTotpTraitTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -17,7 +19,7 @@ final class GetTotpTraitTest extends TestCase
|
|||||||
|
|
||||||
private function makeObject(): object
|
private function makeObject(): object
|
||||||
{
|
{
|
||||||
return new class () {
|
return new class {
|
||||||
use GetTotpTrait;
|
use GetTotpTrait;
|
||||||
|
|
||||||
public function publicGetTotp(): TOTPInterface
|
public function publicGetTotp(): TOTPInterface
|
||||||
@@ -27,18 +29,18 @@ final class GetTotpTraitTest extends TestCase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSetConfigSetsProperty(): void
|
public function test_set_config_sets_property(): void
|
||||||
{
|
{
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
$config = $this->makeConfig();
|
$config = $this->makeConfig();
|
||||||
|
|
||||||
$obj->setConfig($config);
|
$obj->setConfig($config);
|
||||||
|
|
||||||
$reflection = new \ReflectionProperty($obj, 'config');
|
$reflection = new ReflectionProperty($obj, 'config');
|
||||||
self::assertSame($config, $reflection->getValue($obj));
|
self::assertSame($config, $reflection->getValue($obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetTotpReturnsTotpInterface(): void
|
public function test_get_totp_returns_totp_interface(): void
|
||||||
{
|
{
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
$obj->setConfig($this->makeConfig());
|
$obj->setConfig($this->makeConfig());
|
||||||
@@ -48,7 +50,7 @@ final class GetTotpTraitTest extends TestCase
|
|||||||
self::assertInstanceOf(TOTPInterface::class, $totp);
|
self::assertInstanceOf(TOTPInterface::class, $totp);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetTotpReturnsValidCode(): void
|
public function test_get_totp_returns_valid_code(): void
|
||||||
{
|
{
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
$obj->setConfig($this->makeConfig());
|
$obj->setConfig($this->makeConfig());
|
||||||
@@ -59,7 +61,7 @@ final class GetTotpTraitTest extends TestCase
|
|||||||
self::assertSame($this->validTotpCode(), $totp->now());
|
self::assertSame($this->validTotpCode(), $totp->now());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetTotpThrowsOnInvalidUri(): void
|
public function test_get_totp_throws_on_invalid_uri(): void
|
||||||
{
|
{
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
$clock = $this->frozenClock();
|
$clock = $this->frozenClock();
|
||||||
@@ -83,11 +85,11 @@ final class GetTotpTraitTest extends TestCase
|
|||||||
// Factory::loadFromProvisioningUri throws InvalidProvisioningUriException
|
// Factory::loadFromProvisioningUri throws InvalidProvisioningUriException
|
||||||
// which is not caught by getTotp() since the instanceof check only runs
|
// which is not caught by getTotp() since the instanceof check only runs
|
||||||
// after a successful load — so we expect a Throwable here
|
// after a successful load — so we expect a Throwable here
|
||||||
$this->expectException(\Throwable::class);
|
$this->expectException(Throwable::class);
|
||||||
$obj->publicGetTotp();
|
$obj->publicGetTotp();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void
|
public function test_get_totp_throws_http_exception_when_not_totp_type(): void
|
||||||
{
|
{
|
||||||
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
|
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
|
||||||
// so the instanceof check in getTotp() should throw an HttpException(500)
|
// so the instanceof check in getTotp() should throw an HttpException(500)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ final class HasLoggerTraitTest extends TestCase
|
|||||||
{
|
{
|
||||||
use HasLoggerTrait;
|
use HasLoggerTrait;
|
||||||
|
|
||||||
public function testSetLogger(): void
|
public function test_set_logger(): void
|
||||||
{
|
{
|
||||||
$logger = $this->createStub(LoggerInterface::class);
|
$logger = $this->createStub(LoggerInterface::class);
|
||||||
$this->setLogger($logger);
|
$this->setLogger($logger);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Unit\Trait;
|
namespace App\Tests\Unit\Trait;
|
||||||
|
|
||||||
use App\Trait\MakeNonceTrait;
|
use App\Trait\MakeNonceTrait;
|
||||||
|
use DateInterval;
|
||||||
|
use DateTimeInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Cache\CacheItemInterface;
|
use Psr\Cache\CacheItemInterface;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
@@ -20,7 +22,7 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
{
|
{
|
||||||
private function makeObject(): object
|
private function makeObject(): object
|
||||||
{
|
{
|
||||||
return new class () {
|
return new class {
|
||||||
use MakeNonceTrait;
|
use MakeNonceTrait;
|
||||||
|
|
||||||
public function publicMakeNonce(int $retries = 3): string
|
public function publicMakeNonce(int $retries = 3): string
|
||||||
@@ -35,7 +37,7 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeNonceReturnsBase64UrlString(): void
|
public function test_make_nonce_returns_base64_url_string(): void
|
||||||
{
|
{
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
$obj->setLogger(new NullLogger());
|
$obj->setLogger(new NullLogger());
|
||||||
@@ -45,12 +47,12 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
|
|
||||||
self::assertIsString($nonce);
|
self::assertIsString($nonce);
|
||||||
// 15 bytes -> 20 base64 chars without padding
|
// 15 bytes -> 20 base64 chars without padding
|
||||||
self::assertSame(20, strlen($nonce));
|
self::assertSame(20, \strlen($nonce));
|
||||||
// base64url charset only
|
// base64url charset only
|
||||||
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
|
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeNonceStoresNonceInCache(): void
|
public function test_make_nonce_stores_nonce_in_cache(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
@@ -66,7 +68,7 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
self::assertTrue($item->get());
|
self::assertTrue($item->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeNonceSetsExpiry(): void
|
public function test_make_nonce_sets_expiry(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
@@ -82,7 +84,7 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
self::assertGreaterThan(time(), (int) $expiry);
|
self::assertGreaterThan(time(), (int) $expiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTwoNoncesAreDifferent(): void
|
public function test_two_nonces_are_different(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$obj = $this->makeObject();
|
$obj = $this->makeObject();
|
||||||
@@ -95,7 +97,7 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
self::assertNotSame($nonce1, $nonce2);
|
self::assertNotSame($nonce1, $nonce2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeNonceThrowsAfterMaxRetries(): void
|
public function test_make_nonce_throws_after_max_retries(): void
|
||||||
{
|
{
|
||||||
// Create a stub pool that always reports every key as a hit (collision)
|
// Create a stub pool that always reports every key as a hit (collision)
|
||||||
$pool = $this->createStub(CacheItemPoolInterface::class);
|
$pool = $this->createStub(CacheItemPoolInterface::class);
|
||||||
@@ -115,7 +117,7 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
$obj->publicMakeNonce();
|
$obj->publicMakeNonce();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void
|
public function test_make_nonce_retries_and_succeeds_after_collision(): void
|
||||||
{
|
{
|
||||||
// Use a spy pool that returns isHit=true on the first getItem call
|
// Use a spy pool that returns isHit=true on the first getItem call
|
||||||
// (simulating a collision), then delegates to a real ArrayAdapter for
|
// (simulating a collision), then delegates to a real ArrayAdapter for
|
||||||
@@ -123,8 +125,9 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
$realPool = new ArrayAdapter();
|
$realPool = new ArrayAdapter();
|
||||||
$collisionCount = 0;
|
$collisionCount = 0;
|
||||||
|
|
||||||
$spyPool = new class ($realPool, $collisionCount) implements CacheItemPoolInterface {
|
$spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
|
||||||
private int $hits = 0;
|
private int $hits = 0;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private CacheItemPoolInterface $inner,
|
private CacheItemPoolInterface $inner,
|
||||||
private int &$hitCounter,
|
private int &$hitCounter,
|
||||||
@@ -135,69 +138,85 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
{
|
{
|
||||||
$item = $this->inner->getItem($key);
|
$item = $this->inner->getItem($key);
|
||||||
// pretend the first requested key is already a hit (collision)
|
// pretend the first requested key is already a hit (collision)
|
||||||
if ($this->hits === 0) {
|
if (0 === $this->hits) {
|
||||||
$this->hits++;
|
++$this->hits;
|
||||||
$this->hitCounter++;
|
++$this->hitCounter;
|
||||||
return new class ($key) implements CacheItemInterface {
|
|
||||||
|
return new class($key) implements CacheItemInterface {
|
||||||
public function __construct(private string $key)
|
public function __construct(private string $key)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getKey(): string
|
public function getKey(): string
|
||||||
{
|
{
|
||||||
return $this->key;
|
return $this->key;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get(): mixed
|
public function get(): mixed
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isHit(): bool
|
public function isHit(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function set(mixed $value): static
|
public function set(mixed $value): static
|
||||||
{
|
{
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
public function expiresAt(?\DateTimeInterface $expiration): static
|
|
||||||
|
public function expiresAt(?DateTimeInterface $expiration): static
|
||||||
{
|
{
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
public function expiresAfter(int|\DateInterval|null $time): static
|
|
||||||
|
public function expiresAfter(int|DateInterval|null $time): static
|
||||||
{
|
{
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return $item;
|
return $item;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getItems(array $keys = []): iterable
|
public function getItems(array $keys = []): iterable
|
||||||
{
|
{
|
||||||
return $this->inner->getItems($keys);
|
return $this->inner->getItems($keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasItem(string $key): bool
|
public function hasItem(string $key): bool
|
||||||
{
|
{
|
||||||
return $this->inner->hasItem($key);
|
return $this->inner->hasItem($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clear(): bool
|
public function clear(): bool
|
||||||
{
|
{
|
||||||
return $this->inner->clear();
|
return $this->inner->clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteItem(string $key): bool
|
public function deleteItem(string $key): bool
|
||||||
{
|
{
|
||||||
return $this->inner->deleteItem($key);
|
return $this->inner->deleteItem($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteItems(array $keys): bool
|
public function deleteItems(array $keys): bool
|
||||||
{
|
{
|
||||||
return $this->inner->deleteItems($keys);
|
return $this->inner->deleteItems($keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(CacheItemInterface $item): bool
|
public function save(CacheItemInterface $item): bool
|
||||||
{
|
{
|
||||||
return $this->inner->save($item);
|
return $this->inner->save($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveDeferred(CacheItemInterface $item): bool
|
public function saveDeferred(CacheItemInterface $item): bool
|
||||||
{
|
{
|
||||||
return $this->inner->saveDeferred($item);
|
return $this->inner->saveDeferred($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function commit(): bool
|
public function commit(): bool
|
||||||
{
|
{
|
||||||
return $this->inner->commit();
|
return $this->inner->commit();
|
||||||
@@ -211,11 +230,11 @@ final class MakeNonceTraitTest extends TestCase
|
|||||||
// should retry and succeed on the second attempt
|
// should retry and succeed on the second attempt
|
||||||
$nonce = $obj->publicMakeNonce();
|
$nonce = $obj->publicMakeNonce();
|
||||||
self::assertIsString($nonce);
|
self::assertIsString($nonce);
|
||||||
self::assertSame(20, strlen($nonce));
|
self::assertSame(20, \strlen($nonce));
|
||||||
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
|
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void
|
public function test_make_nonce_throws_immediately_with_zero_retries(): void
|
||||||
{
|
{
|
||||||
$pool = $this->createStub(CacheItemPoolInterface::class);
|
$pool = $this->createStub(CacheItemPoolInterface::class);
|
||||||
$item = $this->createStub(CacheItemInterface::class);
|
$item = $this->createStub(CacheItemInterface::class);
|
||||||
|
|||||||
@@ -13,31 +13,31 @@ final class StringTraitTest extends TestCase
|
|||||||
use StringTrait;
|
use StringTrait;
|
||||||
use TotpTestHelper;
|
use TotpTestHelper;
|
||||||
|
|
||||||
public function testMakeCacheKeySanitizesInvalidChars(): void
|
public function test_make_cache_key_sanitizes_invalid_chars(): void
|
||||||
{
|
{
|
||||||
self::assertSame('hello_world', $this->makeCacheKey('hello world'));
|
self::assertSame('hello_world', $this->makeCacheKey('hello world'));
|
||||||
self::assertSame('hello_world', $this->makeCacheKey('hello!world'));
|
self::assertSame('hello_world', $this->makeCacheKey('hello!world'));
|
||||||
self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d'));
|
self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeCacheKeyPreservesValidChars(): void
|
public function test_make_cache_key_preserves_valid_chars(): void
|
||||||
{
|
{
|
||||||
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
|
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeCacheKeyTruncatesLongNames(): void
|
public function test_make_cache_key_truncates_long_names(): void
|
||||||
{
|
{
|
||||||
$long = str_repeat('a', 300);
|
$long = str_repeat('a', 300);
|
||||||
$result = $this->makeCacheKey($long);
|
$result = $this->makeCacheKey($long);
|
||||||
self::assertSame(128, mb_strlen($result));
|
self::assertSame(128, mb_strlen($result));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeCacheKeyEmptyString(): void
|
public function test_make_cache_key_empty_string(): void
|
||||||
{
|
{
|
||||||
self::assertSame('', $this->makeCacheKey(''));
|
self::assertSame('', $this->makeCacheKey(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeCacheKeyWithOnlyInvalidChars(): void
|
public function test_make_cache_key_with_only_invalid_chars(): void
|
||||||
{
|
{
|
||||||
// preg_replace with + collapses consecutive invalid chars into one _
|
// preg_replace with + collapses consecutive invalid chars into one _
|
||||||
self::assertSame('_', $this->makeCacheKey('!!!'));
|
self::assertSame('_', $this->makeCacheKey('!!!'));
|
||||||
@@ -46,7 +46,7 @@ final class StringTraitTest extends TestCase
|
|||||||
self::assertSame('_', $this->makeCacheKey('!@ #'));
|
self::assertSame('_', $this->makeCacheKey('!@ #'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeCacheKeyTruncatesToExactly128(): void
|
public function test_make_cache_key_truncates_to_exactly128(): void
|
||||||
{
|
{
|
||||||
$input = str_repeat('a', 128);
|
$input = str_repeat('a', 128);
|
||||||
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
|
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
|
||||||
@@ -56,7 +56,7 @@ final class StringTraitTest extends TestCase
|
|||||||
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
|
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeCacheKeyWithMultibyteChars(): void
|
public function test_make_cache_key_with_multibyte_chars(): void
|
||||||
{
|
{
|
||||||
// multibyte chars are replaced with a single underscore
|
// multibyte chars are replaced with a single underscore
|
||||||
$result = $this->makeCacheKey('héllo wörld');
|
$result = $this->makeCacheKey('héllo wörld');
|
||||||
@@ -64,7 +64,7 @@ final class StringTraitTest extends TestCase
|
|||||||
self::assertSame('h_llo_w_rld', $result);
|
self::assertSame('h_llo_w_rld', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMakeCacheKeyWithEmoji(): void
|
public function test_make_cache_key_with_emoji(): void
|
||||||
{
|
{
|
||||||
$result = $this->makeCacheKey('a🎉b');
|
$result = $this->makeCacheKey('a🎉b');
|
||||||
self::assertSame('a_b', $result);
|
self::assertSame('a_b', $result);
|
||||||
@@ -72,7 +72,7 @@ final class StringTraitTest extends TestCase
|
|||||||
|
|
||||||
/* ── authSuccessResponse ──────────────────────────────────────────── */
|
/* ── authSuccessResponse ──────────────────────────────────────────── */
|
||||||
|
|
||||||
public function testAuthSuccessResponseSessionMode(): void
|
public function test_auth_success_response_session_mode(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'session');
|
$config = $this->makeConfig(remoteUserMode: 'session');
|
||||||
$response = $this->authSuccessResponse('alice', $config);
|
$response = $this->authSuccessResponse('alice', $config);
|
||||||
@@ -82,7 +82,7 @@ final class StringTraitTest extends TestCase
|
|||||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthSuccessResponseStaticMode(): void
|
public function test_auth_success_response_static_mode(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
||||||
$response = $this->authSuccessResponse('alice', $config);
|
$response = $this->authSuccessResponse('alice', $config);
|
||||||
@@ -91,7 +91,7 @@ final class StringTraitTest extends TestCase
|
|||||||
self::assertSame('authenticated', $response->headers->get('Remote-User'));
|
self::assertSame('authenticated', $response->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthSuccessResponseMappedMode(): void
|
public function test_auth_success_response_mapped_mode(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
||||||
$response = $this->authSuccessResponse('alice', $config);
|
$response = $this->authSuccessResponse('alice', $config);
|
||||||
@@ -99,7 +99,7 @@ final class StringTraitTest extends TestCase
|
|||||||
self::assertSame('admin', $response->headers->get('Remote-User'));
|
self::assertSame('admin', $response->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthSuccessResponseMappedModeFallback(): void
|
public function test_auth_success_response_mapped_mode_fallback(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
||||||
$response = $this->authSuccessResponse('unknown', $config);
|
$response = $this->authSuccessResponse('unknown', $config);
|
||||||
@@ -107,7 +107,7 @@ final class StringTraitTest extends TestCase
|
|||||||
self::assertSame('unknown', $response->headers->get('Remote-User'));
|
self::assertSame('unknown', $response->headers->get('Remote-User'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAuthSuccessResponseNoneModeOmitsHeader(): void
|
public function test_auth_success_response_none_mode_omits_header(): void
|
||||||
{
|
{
|
||||||
$config = $this->makeConfig(remoteUserMode: 'none');
|
$config = $this->makeConfig(remoteUserMode: 'none');
|
||||||
$response = $this->authSuccessResponse('alice', $config);
|
$response = $this->authSuccessResponse('alice', $config);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Unit;
|
namespace App\Tests\Unit;
|
||||||
|
|
||||||
use App\Utilities;
|
use App\Utilities;
|
||||||
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Clock\ClockInterface;
|
use Psr\Clock\ClockInterface;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
@@ -15,10 +16,11 @@ final class UtilitiesTest extends TestCase
|
|||||||
{
|
{
|
||||||
$pool ??= new ArrayAdapter();
|
$pool ??= new ArrayAdapter();
|
||||||
$clock ??= $this->createStub(ClockInterface::class);
|
$clock ??= $this->createStub(ClockInterface::class);
|
||||||
|
|
||||||
return new Utilities($clock, $pool);
|
return new Utilities($clock, $pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadTotpReturnsCachedValueWhenPresent(): void
|
public function test_load_totp_returns_cached_value_when_present(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$item = $pool->getItem('totp');
|
$item = $pool->getItem('totp');
|
||||||
@@ -32,7 +34,7 @@ final class UtilitiesTest extends TestCase
|
|||||||
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
|
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadTotpGeneratesAndStoresWhenMissing(): void
|
public function test_load_totp_generates_and_stores_when_missing(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$utilities = $this->makeUtilities($pool);
|
$utilities = $this->makeUtilities($pool);
|
||||||
@@ -48,7 +50,7 @@ final class UtilitiesTest extends TestCase
|
|||||||
self::assertSame($result, $cached->get());
|
self::assertSame($result, $cached->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadTotpSetsFarFutureExpiry(): void
|
public function test_load_totp_sets_far_future_expiry(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$utilities = $this->makeUtilities($pool);
|
$utilities = $this->makeUtilities($pool);
|
||||||
@@ -58,10 +60,10 @@ final class UtilitiesTest extends TestCase
|
|||||||
$cached = $pool->getItem('totp');
|
$cached = $pool->getItem('totp');
|
||||||
$expiry = $cached->getMetadata()['expiry'];
|
$expiry = $cached->getMetadata()['expiry'];
|
||||||
// 2999-12-31 is well in the future, far beyond any reasonable test timestamp
|
// 2999-12-31 is well in the future, far beyond any reasonable test timestamp
|
||||||
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
|
self::assertGreaterThan((new DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadTotpIsIdempotentAfterGeneration(): void
|
public function test_load_totp_is_idempotent_after_generation(): void
|
||||||
{
|
{
|
||||||
$pool = new ArrayAdapter();
|
$pool = new ArrayAdapter();
|
||||||
$utilities = $this->makeUtilities($pool);
|
$utilities = $this->makeUtilities($pool);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Symfony\Component\Dotenv\Dotenv;
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
require dirname(__DIR__).'/vendor/autoload.php';
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user