25 Commits
Author SHA1 Message Date
andrew b75a16a781 Merge remote-tracking branch 'origin/fix/ci-github-rate-limit' into develop
Sync GitHub / sync (push) Successful in 8s
Push Develop / docker (push) Successful in 4m55s
Tests / test (push) Successful in 59s
Push Docker / docker (push) Successful in 4m42s
2026-08-21 16:11:31 -04:00
andrew 9111958bcf Merge branch 'main' into develop 2026-08-21 16:10:30 -04:00
andrew 95dc6bf0ce Merge branch 'main' into fix/ci-github-rate-limit
Sync GitHub / sync (push) Successful in 6s
2026-08-20 16:27:52 -04:00
lyra 472abfdf89 fix(ci): cache composer deps and authenticate to GitHub
Tests / test (pull_request) Successful in 1m21s
Tests / test (push) Successful in 1m30s
Push Develop / docker (push) Successful in 5m12s
Sync GitHub / sync (push) Successful in 9s
The test workflow was hitting GitHub's unauthenticated API rate
limit (60 req/hour) when downloading 95 packages via composer
install --prefer-dist, causing 429 Too Many Requests errors.

Two fixes applied:
1. Cache Composer's download cache (~/.composer/cache) keyed on
   composer.lock hash, so repeated CI runs don't re-download
   packages at all.
2. Configure GitHub OAuth token via SYNC_GITHUB_TOKEN secret to
   raise the rate limit to 5,000 req/hour for cache misses.
2026-08-17 12:03:14 -04:00
lyra e2780ca5f6 fix: allow same-origin fetch in CSP when inline login script is used
Tests / test (pull_request) Successful in 49s
Sync GitHub / sync (push) Successful in 9s
Tests / test (push) Successful in 1m1s
Push Develop / docker (push) Successful in 4m47s
Push Docker / docker (push) Successful in 7m35s
When subdomain redirection is off, the login form is served inline on
the protected host and submission happens via a same-origin fetch() call
in _script.html.twig. The CSP default-src 'none' was blocking that
fetch (connect-src falls back to default-src).

Add connect-src 'self' to the CSP only when the request is not on the
auth subdomain (i.e. when the inline script is present). On the auth
subdomain the form POSTs normally with no inline script, so the stricter
policy still applies there.

This is the least-privilege relaxation: only same-origin connections,
only on pages that need them.
2026-08-17 11:27:46 -04:00
andrew 7a68c933ce Merge pull request 'fix: handle host-prefixed root path in PublicPathMatcher' (#8) from fix/public-path-host-root into main
Push Develop / docker (push) Successful in 4m44s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 45s
Push Docker / docker (push) Successful in 4m43s
Reviewed-on: #8
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-08-13 16:28:05 -04:00
andrew 55f8e9e84c Merge branch 'main' into fix/public-path-host-root
Sync GitHub / sync (push) Successful in 9s
Tests / test (pull_request) Successful in 1m16s
2026-08-13 15:43:54 -04:00
lyra e3cd8c6739 fix: handle host-prefixed root path in PublicPathMatcher
Sync GitHub / sync (push) Successful in 6s
Tests / test (pull_request) Successful in 1m3s
The host-prefix regex required at least one character after the slash
(/\+.+/), so a pattern like 'code.example.com/' was silently dropped
instead of matching the root path '/'. Changed \+.+ to \+.* so the
trailing slash alone is accepted as the path '/'.

Added tests covering the exact bug scenario from PUBLIC_PATHS
config: 'code.digitaladapt.com/,code.digitaladapt.com/public/**'
2026-08-13 14:30:52 -04:00
lyra 235a7866b3 fix: remove incorrect CSS escaping on color values
Tests / test (pull_request) Successful in 1m5s
Push Develop / docker (push) Successful in 6m43s
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 54s
The Twig |e('css') filter was escaping '#' (0x23) to '\23 ' in hex
color values (bg_color, fg_color, error_color), causing browsers to
not recognize them as valid CSS colors. These are admin-configured
environment variables, not user input, so CSS escaping is unnecessary.
2026-08-13 12:07:20 -04:00
lyra c7585e720a feat: add self-bootstrapping dev server script (bin/dev.sh)
Tests / test (pull_request) Successful in 50s
Push Develop / docker (push) Successful in 8m39s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 57s
Manages a local PHP dev server for end-to-end development and testing.
Binds to 0.0.0.0:8773, accessible via Caddy at
https://preauth.lyra-dev.devgnome.com.

Features:
- Self-bootstrapping: installs PHP 8.4 + extensions (including APCu,
  which is critical for nonce cache, rate limiter, and session storage),
  Composer, and project dependencies if missing. Survives terminal
  resets/reboots.
- Enables apc.enable_cli=1 for console commands (matches Dockerfile)
- Subcommands: start, stop, status, restart
- Sets APP_SHARE_DIR to var/share for filesystem session persistence
- Clears dev cache on start

No database needed — preauth uses APCu + filesystem cache exclusively.

Port assignment: P-R-E = 7-7-3 → 8773
2026-08-13 11:56:55 -04:00
andrew 66b960ccea Merge pull request 'feat: v1.1 — public rate-limited access' (#5) from feat/v1.1-public-access into main
Push Develop / docker (push) Successful in 4m47s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 55s
Push Docker / docker (push) Successful in 4m46s
Reviewed-on: #5
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-08-13 01:30:13 -04:00
andrew 17c2d525ff Merge branch 'main' into feat/v1.1-public-access
Sync GitHub / sync (push) Successful in 13s
Tests / test (pull_request) Successful in 54s
2026-08-13 01:19:08 -04:00
andrew 72c41fec77 Merge pull request 'fix: v1.0 release — security hardening, code quality, and documentation' (#4) from fix/v1.0-must-fix into main
Push Develop / docker (push) Successful in 4m47s
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 57s
Push Docker / docker (push) Successful in 4m48s
Reviewed-on: #4
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-08-12 22:31:59 -04:00
lyra 29e471c536 Merge branch 'fix/v1.0-must-fix' into feat/v1.1-public-access
Push Develop / docker (push) Successful in 6m1s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m3s
2026-08-12 11:21:42 -04:00
lyra 44e4c60f80 fix: restore develop branch trigger support for CI workflows
Tests / test (pull_request) Successful in 1m1s
Push Develop / docker (push) Successful in 6m29s
Sync GitHub / sync (push) Successful in 10s
Tests / test (push) Successful in 1m0s
Re-adds 'develop' to push triggers in develop.yaml and tests.yaml
so the :develop Docker image can be built from the develop branch,
enabling dev testing without requiring a merge to main.
2026-08-12 11:21:37 -04:00
lyra 5563999525 feat: public rate-limited access for v1.1
Sync GitHub / sync (push) Successful in 8s
Add PublicAccessListener (priority 84) that allows rate-limited
unauthenticated access to configured public paths. Authenticated users
bypass this listener entirely via AcceptListener/AllowListener.

New components:
- PublicPathMatcher service with wildcard path matching (* and **)
  and optional host-prefix scoping
- PublicAccessListener applying per-IP rate limiting to public paths
- Separate public_limiter compound rate limiter (burst + sustained)
- publicRateLimitCache pool (APCu in prod, array in tests)

New env vars:
- PUBLIC_PATHS (comma-separated path patterns, empty = disabled)
- PUBLIC_BURST_COUNT/PUBLIC_BURST_TIME (default 100/60s)
- PUBLIC_UPPER_COUNT/PUBLIC_UPPER_TIME (default 500/3600s)

Tests: 52 new tests (29 unit for PublicPathMatcher, 12 unit for
PublicAccessListener, 11 functional for PublicAccessFlowTest).
Total: 293 tests, 605 assertions, all passing.
PHP CS Fixer: 0 of 63 files need fixing.

Documentation: README, CHANGELOG, ROADMAP, Caddyfile, example.env
all updated with public access configuration and examples.
2026-08-12 09:26:50 -04:00
lyra 9ad54f8e2a feat: configurable Remote-User header (design consideration 1.2)
Sync GitHub / sync (push) Successful in 7s
Tests / test (pull_request) Successful in 58s
Add REMOTE_USER env var with four modes:
- session (default): sends session id, backward-compatible
- static: sends a fixed string (REMOTE_USER_STATIC)
- mapped: looks up session id in REMOTE_USER_MAP
- none: omits the header entirely

New RemoteUserMode enum, ConfigBag parsing/validation, and
StringTrait::authSuccessResponse resolves the header value based
on the configured mode. AcceptListener now receives ConfigBag as
a constructor dependency.

Addresses design consideration 1.2 (Remote-User header value is
user-controlled) from DESIGN_CONSIDERATIONS.md.

241 tests pass, 0 cs-fixer violations.
2026-08-11 22:48:52 -04:00
lyra 2258839bd6 docs: update DESIGN_CONSIDERATIONS.md to reflect addressed items
Sync GitHub / sync (push) Successful in 8s
- Mark all resolved items with  and describe the fix applied
- Mark remaining open items with  and keep recommendations
- Add new sections for items discovered during the fix work:
  - 1.7 CSS injection in style template
  - 1.8 ->json null safety
  - 1.9 validReturn() parse_url false check
  - 1.10 Incomplete TLD list
  - 2.8 Duplicated response construction
  - 2.9 Duplicated constants
  - 5.3 Kernel::terminate() try/finally
  - 9. CI & Workflows (tag format, stale branches, publish.yaml)
- Update 'What's Done Well' to reflect new improvements
- Add summary noting this is a living document tracking the
  fix/v1.0-must-fix branch state
2026-08-11 17:02:27 -04:00
lyra 408d75dda1 chore: nice-to-have improvements for v1.0
Sync GitHub / sync (push) Successful in 8s
Code quality:
- Create AppConstants class with shared constants:
  - FAR_FUTURE_DATE (replaces duplicated '2999-12-31' strings)
  - MAX_INPUT_LENGTH (replaces duplicated 128 in Payload and StringTrait)
- Extract duplicated 'hi $id' response body into StringTrait::authSuccessResponse()
  method, used by AcceptListener, AllowListener, and LoginManager
- Add missing @throws InvalidArgumentException annotations to
  MonitorCacheKeys (getItem, hasItem, deleteItem, deleteItems, commit)

Configuration:
- Add proper env var type casting in services.yaml:
  - COOKIE_TTL → env(int:)
  - SUBDOMAIN_REDIRECT → env(bool:)
  - IP_TTL → env(int:)
  - TEAPOT → env(bool:)

Documentation:
- Create CONTRIBUTING.md with development setup, code style,
  testing guidelines, and PR process
2026-08-11 16:37:23 -04:00
lyra b89070e985 fix: should-fix items for v1.0 release
Documentation:
- Create CHANGELOG.md with full version history (v0.0.1 through unreleased)
- Rewrite README with comprehensive setup guide, configuration reference,
  architecture overview, security model, and feature list
- Update ROADMAP.md: fix branch status table, mark completed security
  review items, update TOTP leeway description
- Fix 'centeral' typo in docs/Caddyfile
- Remove TODO comment from docs/compose.yaml
- Add DESIGN_CONSIDERATIONS.md (design review document)

Code quality:
- Extract duplicated cookie name/domain logic into CookieNameTrait
  methods: sessionCookieName() and sessionCookieDomain()
- Update AcceptListener, AllowListener, InterceptListener, and
  LoginManager to use the shared methods
- Remove fragile cross-file coupling comment between LoginManager
  and InterceptListener

Error handling:
- Wrap cache operations in AcceptListener and AllowListener with
  try/catch to fail closed (don't authenticate on cache errors)
- Log cache errors at error level instead of propagating as 500s
- Early return pattern in AcceptListener and AllowListener for
  cleaner control flow
2026-08-11 16:35:27 -04:00
lyra d2eb914637 fix: must-fix items for v1.0 release
Security:
- Add SecurityHeadersListener (X-Content-Type-Options, X-Frame-Options,
  CSP, Referrer-Policy, HSTS)
- Replace document.write() with document.documentElement.innerHTML
  in login JS to avoid CSP violations
- Add CSS escaping (|e('css')) to env color values in _style.html.twig
- Document CSRF protection model: nonce serves as CSRF token for POST
  form path (single-use, server-generated, 120s TTL)
- Reduce TOTP verification window from 10 periods (±5 min) to 1 (±30s)
- Remove hardcoded APP_SECRET from bin/franken.sh (now uses env or
  generates random)
- Remove backup code values from debug log output
- Add .env to .gitignore

Bug fixes:
- Fix ->json access on possibly-null  in LoginListener
  (uses null-safe operator ?->)
- Fix validReturn() not checking false from parse_url (could cause
  TypeError on malformed URLs)
- Add isHit() race condition check in AcceptListener and AllowListener
- Add try/finally in Kernel::terminate() so parent::terminate() always
  runs even if persist() throws
- Add input validation to GenerateBackupCodesCommand (reject count < 1)
- Use Response::HTTP_INTERNAL_SERVER_ERROR constant in GetTotpTrait
  instead of literal 500

Docker/CI:
- Explicitly install curl in Docker final image (needed for healthcheck)
- Update workflow tag pattern to v*.*.* (standardize on v-prefix)
- Extract version without v-prefix for Docker image tag
- Remove stale develop branch from CI triggers
- Fix publish.yaml git remote add to use set-url on re-runs

Code quality:
- Add declare(strict_types=1) to all interface files
- Add #[AsCommand] attribute to GenerateBackupCodesCommand
- Fix BackupCodeInterface default count to match implementation (10)
- Lowercase host before TLD lookup in DomainManager
- Expand TLD list with many missing multi-part TLDs (.com.au, .co.jp,
  .com.br, .co.kr, .com.tw, .co.za, etc.) to prevent open redirect
  vulnerabilities
- Disable unused Symfony sessions in framework.yaml

Tests:
- Update DomainManagerTest for corrected TLD parsing (.com.au, .co.jp,
  .com.br now correctly recognized as multi-part)
- Update GetTotpTraitTest for corrected error message
- Update GenerateBackupCodesCommandTest: zero count now throws exception
2026-08-11 16:33:00 -04:00
andrew c0bda8aeec Merge pull request 'chore: add php-cs-fixer with PSR-12 config and CI check' (#3) from chore/add-php-cs-fixer into main
Push Develop / docker (push) Successful in 4m48s
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 1m14s
Push Docker / docker (push) Successful in 4m44s
Reviewed-on: #3
2026-08-11 09:58:51 -04:00
lyra cb378e20bc chore: add php-cs-fixer with PSR-12 config and CI check
Sync GitHub / sync (push) Failing after 5s
Tests / test (pull_request) Successful in 49s
- Add friendsofphp/php-cs-fixer to require-dev
- Create .php-cs-fixer.dist.php configured for @PSR12 ruleset
- Add php-cs-fixer dry-run step to CI pipeline
- Auto-fix existing PSR-12 violations
- Document code style tooling in readme.md
2026-08-11 08:30:05 -04:00
lyra 6b5a711fa9 Fix docs, add .dockerignore, fix base64url padding, fix typo
Sync GitHub / sync (push) Successful in 7s
Tests / test (pull_request) Successful in 42s
- Add .dockerignore to exclude .git, vendor, var, tests, docs, .env
  and other non-build files from Docker context
- Fix broken base64url padding in src/Data/Payload.php: str_pad was
  a no-op because the length argument was always < string length.
  Replaced with correct str_repeat approach
- Fix typo in bin/franken.sh: digtialadapt → digitaladapt
- Add comment to bin/franken.sh noting it's a dev utility
- Remove config/reference.php from git tracking (auto-generated file)
  and add to .gitignore
- Fix readme.md: env.example → example.env (matches actual filename)
2026-08-10 18:55:19 -04:00
andrew 95ab77db2a added roadmap for where we are aiming to take this project
Push Develop / docker (push) Successful in 4m43s
Sync GitHub / sync (push) Successful in 6s
Tests / test (push) Successful in 47s
2026-08-07 09:25:56 -04:00
93 changed files with 6135 additions and 1471 deletions
+12
View File
@@ -0,0 +1,12 @@
.git/
.gitignore
var/
vendor/
tests/
.phpunit.cache/
docs/
*.md
.env
.env.test
.env.local
composer.phar
+5
View File
@@ -12,6 +12,11 @@ BURST_COUNT=10
BURST_TIME=30
UPPER_COUNT=100
UPPER_TIME=3600
PUBLIC_PATHS=''
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
PUBLIC_UPPER_COUNT=500
PUBLIC_UPPER_TIME=3600
TITLE='Pre-Authentication System'
BG_COLOR='#029386'
FG_COLOR='#ffffff'
-1
View File
@@ -31,4 +31,3 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:develop
+6 -3
View File
@@ -3,7 +3,7 @@ name: Push Docker
on:
push:
tags:
- '*.*.*'
- 'v*.*.*'
jobs:
docker:
@@ -22,6 +22,10 @@ jobs:
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:
@@ -30,5 +34,4 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:latest
${{ vars.DOCKERHUB_TARGET }}:${{ github.ref_name }}
${{ vars.DOCKERHUB_TARGET }}:${{ steps.version.outputs.VERSION }}
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
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"
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: |
+21
View File
@@ -26,8 +26,29 @@ jobs:
coverage: xdebug
ini-values: apc.enable_cli=1
# Authenticate to GitHub to raise API rate limit from 60 → 5,000 req/hour.
# Uses the same token that publish.yaml uses to sync to GitHub.
- name: Configure GitHub OAuth token
env:
GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
run: composer config --global github-oauth.github.com "$GITHUB_TOKEN"
# Cache Composer's download cache so repeated CI runs don't re-download
# packages at all. Keyed on composer.lock hash — cache busts automatically
# when dependencies change.
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run php-cs-fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff
- name: Run tests
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
+10
View File
@@ -12,3 +12,13 @@
/.phpunit.cache/
/bin/.phpunit.result.cache
###< phpunit/phpunit ###
###> project-specific ###
/config/reference.php
###< project-specific ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
.env
+18
View File
@@ -0,0 +1,18 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->exclude('vendor')
->notPath([
'config/bundles.php',
'config/reference.php',
])
;
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
])
->setFinder($finder)
;
+166
View File
@@ -0,0 +1,166 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] — v1.1
### Added
- **Public rate-limited access** — Select paths can now be made publicly
accessible without TOTP authentication, with separate per-IP rate limiting.
This is useful for exposing public content (e.g., public Gitea repositories)
while protecting server resources from bot traffic.
- New `PUBLIC_PATHS` env var: comma-separated path patterns with `*` (single
segment) and `**` (cross-segment) wildcard support. Optional host prefix
(e.g., `code.example.com/public/**`). When empty (default), the feature
is fully disabled.
- New `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` env vars for burst rate
limiting (default: 100 requests per 60 seconds).
- New `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` env vars for sustained
rate limiting (default: 500 requests per 3600 seconds).
- Authenticated users bypass the public rate limiter entirely.
- Over-limit responses include a `Retry-After` header.
- New `PublicPathMatcher` service for path pattern matching.
- New `PublicAccessListener` (priority 84) in the request pipeline.
## [1.0.0] — v1.0 Release
### Security
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
variable with four modes: `session` (default), `static`, `mapped`, and `none`.
This allows deployments to prevent user-controlled header values from reaching
backend services.
- Added `SecurityHeadersListener` to set `X-Content-Type-Options`, `X-Frame-Options`,
`Content-Security-Policy`, `Referrer-Policy`, and `Strict-Transport-Security`
headers on all responses.
- Replaced `document.write()` with `document.documentElement.innerHTML` in login
page JavaScript to avoid CSP violations.
- Added CSS escaping (`|e('css')`) to environment-configured color values in
the login page template to prevent CSS injection.
- Documented CSRF protection model: the nonce system provides CSRF protection
for POST form logins (server-generated, single-use, 120s TTL).
- Reduced TOTP verification window from 10 periods (±5 minutes) to 1 period
(±30 seconds) to reduce brute-force attack surface.
- Removed hardcoded `APP_SECRET` from `bin/franken.sh` (now uses environment
variable or generates a random secret).
- Removed backup code values from debug log output.
- Added `.env` to `.gitignore`.
- Expanded TLD list in `DomainManager` with many missing multi-part TLDs
(`.com.au`, `.co.jp`, `.com.br`, `.co.kr`, `.com.tw`, `.co.za`, etc.)
to prevent open redirect vulnerabilities from incorrect domain matching.
- Lowercased host before TLD lookup to fix case-sensitivity issue.
### Fixed
- Fixed `$payload->json` access on possibly-null `$payload` in `LoginListener`
using null-safe operator (`?->`).
- Fixed `validReturn()` not checking `false` return from `parse_url()`, which
could cause a `TypeError` on malformed URLs.
- Added `isHit()` race condition check in `AcceptListener` and `AllowListener`
between `hasItem()` and `getItem()` calls.
- Added `try/finally` in `Kernel::terminate()` so `parent::terminate()` always
runs even if `persist()` throws an exception.
- Added input validation to `GenerateBackupCodesCommand` — rejects count < 1.
### Changed
- Disabled unused Symfony sessions in `framework.yaml` (preauth implements its
own cookie/cache-based session management).
- Standardized git tag format to use `v` prefix (`v1.0.0` instead of `1.0.0`).
- Updated CI workflows to use `v*.*.*` tag pattern and strip `v` prefix for
Docker image tags.
- Removed stale `develop` branch from CI triggers.
- Fixed `publish.yaml` to use `git remote set-url` on re-runs instead of
failing when the remote already exists.
- Explicitly install `curl` in the Docker final image (needed for healthcheck).
- Added `declare(strict_types=1)` to all interface files.
- Added `#[AsCommand]` attribute to `GenerateBackupCodesCommand`.
- Fixed `BackupCodeInterface` default count to match implementation (10).
- Used `Response::HTTP_INTERNAL_SERVER_ERROR` constant in `GetTotpTrait`
instead of literal `500`.
## [0.10.0] - 2026-08-11
### Added
- PHP-CS-Fixer with PSR-12 configuration and CI check.
## [0.9.0] - 2026-07-15
### Added
- PHPUnit test suite — 222 tests, 100% code coverage (lines, methods, classes).
## [0.8.1] - 2026-05-30
### Fixed
- Bug fixes and cleanup from develop branch merge.
## [0.8.0] - 2026-05-29
### Changed
- Renamed form fields for clarity.
- Fixed invalid login bug.
## [0.7.0] - 2026-05-29
### Added
- Single-use backup codes via `app:generate-backup-codes` console command.
- Cache persistence improvement — only write changed keys to file storage.
### Removed
- Static password and lookup token (security risks).
### Changed
- Updated to PHP 8.5, updated dependencies.
## [0.6.0] - 2026-02-10
### Added
- Optional (disabled by default) ability to lookup token by static password.
## [0.5.0] - 2026-01-17
### Added
- Optional (disabled by default) ability to use a static password as backup auth.
### Changed
- Nonce-related cleanup.
## [0.4.1] - 2025-12-26
### Fixed
- Bug which can occur if cache files are deleted.
## [0.4.0] - 2025-12-26
### Changed
- Massive rewrite to listener-based architecture instead of controllers.
- Login payload sent via `X-Preauth` header instead of GET request parameters.
- Enhanced cookie security.
- Removed icon system and asset system.
## [0.3.0] - 2025-12-15
### Changed
- **Breaking:** Default port and transport changed to HTTP on port 80.
- **Breaking:** Environment variable names have changed.
- Refactored to Symfony 7.4 with FrankenPHP.
## [0.2.0] - 2025-12-03
### Added
- Login rate limiting (burst + upper window).
- Error page for rate-limited clients ("too many requests").
- Example Docker Compose file.
## [0.1.0] - 2025-11-14
### Added
- Docker image published to Docker Hub.
- PHP-FPM based, code in `src/`, templates in separate files.
## [0.0.1] - 2024-06-26
### Notes
- Started as a single-file script in Caddy config. Hardcoded TOTP secret,
zero flexibility, but functional. Ran quietly in production for about a
year before any real development began.
+74
View File
@@ -0,0 +1,74 @@
# Contributing to Preauth
Thank you for your interest in contributing to Preauth! This document
outlines the process for contributing to the project.
## Development Setup
1. Clone the repository
2. Install dependencies: `composer install`
3. Copy `.env.example` to `.env` and configure as needed
4. Run tests: `vendor/bin/phpunit`
## Code Style
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and
includes `php-cs-fixer` as a dev dependency.
```bash
# Check for style violations
vendor/bin/php-cs-fixer fix --dry-run --diff
# Auto-fix
vendor/bin/php-cs-fixer fix
```
All code must pass the style check before it can be merged.
## Testing
All code changes must include tests. The project maintains 100% code
coverage — new code must be fully tested.
```bash
# Run tests
vendor/bin/phpunit
# Run with coverage (requires Xdebug)
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
```
### Test Structure
- **Unit tests** go in `tests/Unit/` and mirror the `src/` directory structure
- **Functional tests** go in `tests/Functional/` and test the full HTTP kernel
- Use the support traits (`TotpTestHelper`, `ListenerTestHelper`) for
reusable test fixtures
## Pull Request Process
1. Create a feature branch from `main`
2. Make your changes, ensuring tests pass and code style is clean
3. Update documentation if needed (README, CHANGELOG, docs/)
4. Submit a pull request to `main`
### Commit Messages
Use conventional commit format:
- `feat:` new feature
- `fix:` bug fix
- `docs:` documentation only
- `refactor:` code change that neither fixes a bug nor adds a feature
- `test:` adding or correcting tests
- `chore:` build process, tooling, etc.
## Architecture
Preauth is an event-listener-driven Symfony application (no controllers).
See `ROADMAP.md` for the full architecture overview and design decisions.
## License
By contributing, you agree that your contributions will be licensed under
the MIT License.
+298
View File
@@ -0,0 +1,298 @@
# Design Considerations — Preauth
## Summary
Preauth is a well-architected TOTP-based authentication gateway that has evolved from a single-file script into a clean, event-listener-driven Symfony application with 100% test coverage. The codebase demonstrates strong security fundamentals (host-prefixed cookies, nonce-based replay protection, rate limiting, backup code system) and thoughtful operational design (dual-layer cache with change tracking, FrankenPHP worker mode).
This document was originally prepared as a design review. Items that have been addressed are marked with ✅ and include a reference to the commit or change that resolved them. Items still open are marked with ⬜ and remain as recommendations for future work.
---
## 1. Security
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
**Current state:** Fixed. A `SecurityHeadersListener` (response event, priority 0) now sets the following headers on all main-request responses:
```
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000
```
The inline `<script>` and `<style>` in the templates mean a CSP with `'unsafe-inline'` for `script-src` and `style-src` is the strictest practical policy today. Moving scripts/styles to external files would allow a stricter CSP in the future.
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ✅ Addressed
**Current state:** Fixed. The `Remote-User` header value is now configurable via the `REMOTE_USER` environment variable, which supports four modes:
- **`session`** (default, backward-compatible): Sends the session id, as before. The value is still sanitized via `makeCacheKey()`.
- **`static`**: Sends a fixed string (configurable via `REMOTE_USER_STATIC`, default `authenticated`) for all authenticated requests. This eliminates the user-controlled header issue entirely.
- **`mapped`**: Looks up the session id in a configured map (`REMOTE_USER_MAP`, format: `id1:user1,id2:user2`) and sends the mapped value. Falls back to the session id if not found in the map. This is the path to multi-user support.
- **`none`**: Omits the `Remote-User` header entirely. Caddy's `forward_auth` still accepts the request based on the 200 status code.
The `RemoteUserMode` enum (`src/Enum/RemoteUserMode.php`) encapsulates the modes. `StringTrait::authSuccessResponse()` resolves the header value based on the configured mode, and `ConfigBag` handles parsing the map string and validating the mode (invalid values fall back to `session`). `AcceptListener` now receives `ConfigBag` as a constructor dependency to support this.
### 1.3 No CSRF Protection on POST Form Login [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Resolved through documentation and analysis. The nonce system provides CSRF protection for the POST form path: nonces are server-generated, single-use, and have a 120-second TTL. An attacker cannot forge a POST request without first loading the login page to obtain a valid nonce, which requires being on the auth subdomain. The `LoginListener` class docblock and `login.html.twig` template comment now explicitly document this CSRF protection model. The AJAX (header) path embeds the nonce in the base64url payload.
### 1.4 TOTP Verification Leeway May Be Too Generous [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. The TOTP verification window has been reduced from 10 periods (±5 minutes) to 1 period (±30 seconds). With the default 30-second TOTP period, a code is now valid for at most 90 seconds (the current window plus one window on each side), down from the previous 50 seconds per window with 10-period leeway. The ROADMAP has been updated to reflect this change.
### 1.5 Backup Code Logging Reveals Code Value [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. The debug log in `BackupCodeManager::verifyAndConsume()` no longer includes the backup key name. It now logs only the hit/miss and valid/invalid status: `"checking backup code: HIT & VALID"` or `"checking backup code: miss & invalid"`.
### 1.6 TOTP Object Reconstructed on Every Verification [LOW PRIORITY] ⬜ Open
**Current state:** `GetTotpTrait::getTotp()` calls `OTHP\Factory::loadFromProvisioningUri()` on every invocation. This parses the OTP URI string and constructs a new TOTP object each time a token is verified.
**Note:** An attempt was made to memoize the TOTP object within the request cycle, but PHP 8.4's `readonly` class constraint prevents traits from defining mutable properties in `readonly` classes (`LoginManager` and `BackupCodeManager` are both `final readonly`). Resolving this would require either removing `readonly` from these classes, using a separate memoization service, or refactoring `GetTotpTrait` into a dedicated injectable service.
**Why:** This is a minor performance concern — URI parsing and TOTP object construction happen on every login attempt. In a FrankenPHP worker process that handles many requests, this adds unnecessary overhead. It's not a security issue, but it's an easy optimization if the readonly constraint is relaxed.
### 1.7 CSS Injection in Style Template [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. Environment-configured color values (`bg_color`, `fg_color`, `error_color`) in `_style.html.twig` are now escaped with Twig's `|e('css')` filter to prevent CSS injection from malicious environment variable values.
### 1.8 $payload->json Access on Possibly-Null Payload [HIGH PRIORITY] ✅ Addressed
**Current state:** Fixed. In `LoginListener::onKernelRequest()`, the `$payload->json` access on a possibly-null `$payload` has been replaced with `$payload?->json ?? true`, and `$payload->id` with `$payload?->id ?? ''`. This prevents a crash when a login attempt is detected (e.g., via the `X-Preauth` header) but the payload is invalid (malformed base64, non-object JSON, etc.).
### 1.9 validReturn() Doesn't Check false from parse_url [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. `DomainManager::validReturn()` now checks for `false` and empty string in addition to `null` when examining the return value of `parse_url($url, PHP_URL_HOST)`. This prevents a `TypeError` on malformed URLs that `filter_var(FILTER_VALIDATE_URL)` accepts but `parse_url` cannot parse.
### 1.10 Incomplete TLD List in DomainManager [HIGH PRIORITY] ✅ Addressed
**Current state:** Fixed. The TLD lookup table in `DomainManager` has been significantly expanded with many previously missing multi-part TLDs, including `.com.au`, `.co.jp`, `.com.br`, `.co.kr`, `.com.tw`, `.co.za`, and dozens more. Without these entries, domains like `evil.com.au` would incorrectly match `auth.example.com.au` (both would resolve to base `com.au`), creating an open redirect vulnerability. The host is also now lowercased before TLD lookup to fix a case-sensitivity issue.
---
## 2. Architecture & Code Quality
### 2.1 Trait-Based Dependency Injection Pattern [MEDIUM PRIORITY] ⬜ Open
**Current state:** Several traits (`HasLoggerTrait`, `GetTotpTrait`, `MakeNonceTrait`) use `#[Required]` attribute for setter injection into `readonly` classes. For example, `LoginManager` receives `$config`, `$logger`, and `$nonceCache` via traits rather than through its constructor. The constructor only accepts three parameters; the rest are wired via setter methods called by the service container after construction.
**Recommendation:** Move these dependencies into the constructors of the classes that use them. If multiple classes share the same dependencies, that's fine — PHP constructors can accept many parameters, and it makes the dependency graph explicit. Alternatively, create a shared `Dependencies` value object that bundles logger, config, and nonce cache.
**Why:** The trait-based setter injection pattern makes it non-obvious what dependencies a class has — you have to look at both the constructor and all the traits it uses. It also creates a temporal coupling issue: the object exists in a partially-constructed state between construction and setter calls. With `readonly` classes, this works only because the trait properties are declared in the trait, not the class, which is a subtle language detail that could confuse future maintainers. Standard constructor injection is more explicit, testable, and conventional in Symfony.
### 2.2 Duplicated Cookie Logic Between LoginManager and InterceptListener [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Resolved. The duplicated cookie name and domain selection logic has been extracted into two shared methods on `CookieNameTrait`:
- `sessionCookieName(DomainInterface $domainManager): string` — Returns the appropriate cookie name (`__Host-Http-Preauth` or `__Http-Domain-Preauth`) based on whether central auth is active.
- `sessionCookieDomain(DomainInterface $domainManager, string $host): ?string` — Returns the cookie domain for central auth mode, or null for single-domain mode.
`LoginManager::setCookie()`, `AcceptListener::onKernelRequest()`, and `InterceptListener::pruneInvalidCookie()` all now use these shared methods. The fragile "changes here must be reflected in InterceptListener::pruneInvalidCookie()" comment has been removed.
### 2.3 MonitorCacheKeys Instantiated Multiple Times for Same Pool [MEDIUM PRIORITY] ⬜ Open
**Current state:** `MonitorCacheKeys` is a decorator that tracks cache key changes. It's instantiated independently in `PersistCache`, `LoginManager`, and `BackupCodeManager`, each wrapping the same underlying `CacheItemPoolInterface`. The key list (`__key_list`) and change list (`__chg_list`) are stored in the cache itself, so the instances share state — but each instance calls `initialize()` in its constructor if the lists don't exist yet, and each `save()`/`saveDeferred()` call triggers additional metadata writes.
**Recommendation:** Register `MonitorCacheKeys` as a decorated service in the DI container (using Symfony's `decorates` feature) so there's a single instance per cache pool. Or, make `MonitorCacheKeys` a stateless service that's injected once, rather than having each consumer create its own wrapper.
**Why:** Multiple instances wrapping the same pool is wasteful — each `save()` call triggers a cascade of metadata operations (update key list, log change, commit). With three instances, a single cache write could trigger nine additional cache operations. A single decorator service would be more efficient and would make the lifecycle clearer.
### 2.4 Payload Base64url Decoding Has Broken Padding [MEDIUM PRIORITY] ✅ Already Correct
**Current state:** Not an issue. The code correctly uses:
```php
$base64 = strtr($base64url, '-_', '+/');
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
```
This was fixed in a prior commit ("Fix docs, add .dockerignore, fix base64url padding, fix typo"). The original review incorrectly reported the use of `str_pad`; the implementation now correctly uses `str_repeat` to add the proper number of `=` padding characters.
### 2.5 Symfony Sessions Enabled But Unused [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. `config/packages/framework.yaml` now has `session: false` with a comment explaining that preauth implements its own cookie/cache-based session management and does not use Symfony's session subsystem.
### 2.6 config/reference.php Committed to Repository [LOW PRIORITY] ✅ Already Handled
**Current state:** Not an issue. `config/reference.php` is already listed in `.gitignore` under the project-specific section and is not tracked in version control.
### 2.7 Public Properties on Payload DTO [LOW PRIORITY] ⬜ Open
**Current state:** `Payload` uses public properties (`$id`, `$token`, `$nonce`, `$json`, `$scope`) with no encapsulation. The object is mutable after construction.
**Recommendation:** Consider making `Payload` a `readonly` class (PHP 8.4+ supports `readonly` classes natively) with a constructor that takes all fields, or use Symfony's `Stringable`/value object patterns. Since `LoginManager` mutates `$payload->scope` (downgrading IP to Cookie), the current design requires mutability — but this could be handled by returning a new instance instead.
**Why:** Immutable DTOs are safer to pass around, especially in an event-driven system where the same object might be referenced by multiple listeners. The current mutation in `LoginManager::checkToken()` (changing `$payload->scope`) is a side effect that's not obvious from the method signature.
### 2.8 Duplicated "hi $id" Response Construction [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. The duplicated `new Response("hi $id", headers: ['Content-Type' => 'text/plain', 'Remote-User' => $id])` pattern in `AcceptListener`, `AllowListener`, and `LoginManager` has been extracted into `StringTrait::authSuccessResponse(string $id): Response`, which all three classes now use.
### 2.9 Duplicated Constants [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. The duplicated `'2999-12-31'` far-future date string (previously in `Utilities::makeTotp()` and `BackupCodeManager::verifyAndConsume()`/`saveCodes()`) and the `128` max input length (previously in `StringTrait::makeCacheKey()` and `Payload::create()`) have been extracted into `AppConstants::FAR_FUTURE_DATE` and `AppConstants::MAX_INPUT_LENGTH` respectively.
---
## 3. Testing
### 3.1 No Tests for Concurrent Access / Race Conditions [LOW PRIORITY] ⬜ Open
**Current state:** The test suite is excellent — 222 tests, 100% coverage, good edge case coverage. However, there are no tests for concurrent access scenarios, such as two requests using the same nonce simultaneously, or cache initialization race conditions in `MonitorCacheKeys`.
**Recommendation:** Add a few integration tests that simulate concurrent access (e.g., using process forks or mock caches with delays). At minimum, document that concurrent access is expected to be handled by APCu's atomic operations.
**Why:** `MonitorCacheKeys::initialize()` checks if key lists exist and creates them if not — under concurrent startup, two instances could both see missing lists and both call `initialize()`. This is likely fine because APCu operations are atomic, but it's worth having a test or at least a documented assumption. The race condition between `hasItem()` and `getItem()` in `AcceptListener` and `AllowListener` is now handled with an `isHit()` check, but is not tested.
### 3.2 No Security-Focused Test Suite [LOW PRIORITY] ⬜ Open
**Current state:** Security behaviors (nonce replay, backup code reuse, rate limiting) are tested as part of the functional and unit tests, but there's no dedicated security test suite that systematically probes for common vulnerabilities.
**Recommendation:** Consider adding a `tests/Security/` directory with tests for: XSS attempts in the username field, header injection via the `return` parameter, cookie attribute verification (Secure, HttpOnly, SameSite), and response header presence (now that security headers are added).
**Why:** For an authentication gateway, security testing deserves its own focused suite that's easy to find and extend. This also makes it easier for security reviewers to understand what's been tested.
---
## 4. Docker & Deployment
### 4.1 Healthcheck Depends on curl Which May Not Be Installed [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. The Dockerfile now explicitly installs `curl` in the final image with `apt-get install -y --no-install-recommends curl` and cleans up the apt lists to keep the image small.
### 4.2 Typo in bin/franken.sh [LOW PRIORITY] ✅ Already Fixed / Addressed
**Current state:** Fixed. The typo (`digtialadapt``digitaladapt`) was corrected in a prior commit. The script has since been further improved: the hardcoded `APP_SECRET` has been removed (now uses the `APP_SECRET` environment variable or generates a random secret), and the `docker container rm` command now suppresses errors when the container doesn't exist.
### 4.3 No .dockerignore File [LOW PRIORITY] ✅ Already Handled
**Current state:** Not an issue. A `.dockerignore` file exists and excludes `.git/`, `.gitignore`, `var/`, `vendor/`, `tests/`, `.phpunit.cache/`, `docs/`, `*.md`, `.env`, `.env.test`, `.env.local`, and `composer.phar` from the Docker build context. This was added in a prior commit.
### 4.4 Dockerfile Uses PHP 8.5 Which Is Bleeding Edge [LOW PRIORITY] ⬜ Open (Deliberate)
**Current state:** The Dockerfile uses `php:8.5-trixie` for the build stage and `dunglas/frankenphp:php8.5-trixie` for the final image. `composer.json` requires `php >= 8.4`. The CI workflow in `tests.yaml` also uses PHP 8.5.
**Recommendation:** This is a deliberate choice and likely fine for a personal project. If broader compatibility is desired, consider testing against both PHP 8.4 and 8.5 in CI. The `composer.json` already allows 8.4+.
---
## 5. Error Handling
### 5.1 Cache Exceptions Propagate as 500 Errors [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. Cache operations in `AcceptListener::onKernelRequest()` and `AllowListener::onKernelRequest()` are now wrapped in try/catch blocks that catch `Psr\Cache\InvalidArgumentException`. On a cache error, the listener logs the error at `error` level and returns without setting a response — causing the request to fall through to the next listener, which will eventually present the login page. This is a "fail closed" approach: if the cache is unavailable, the user is not authenticated.
**Note:** `LoginManager::checkToken()` and `BackupCodeManager::verifyAndConsume()` still declare `@throws InvalidArgumentException`. These are called from `LoginListener`, which does not catch the exception. A cache failure during login verification would still result in a 500 error. This is a lower-priority concern since login failures already result in a 401 response path.
### 5.2 No Global Exception Handling for Auth Flow [LOW PRIORITY] ⬜ Open
**Current state:** There is no `ExceptionListener` or `ErrorController` configured. Symfony's default error handling will produce a generic error page for uncaught exceptions. In dev mode (`APP_DEBUG=1`), this shows a full stack trace.
**Recommendation:** Add a simple exception listener that catches exceptions from the auth flow and returns a clean 401 or 503 response with the login page or error template. Alternatively, configure `framework.error_controller` to use a custom controller that renders the error template.
**Why:** For an auth gateway, every response should be intentional. A raw Symfony error page (even in production mode) doesn't match the styled login/error pages and could leak information about the internal architecture.
### 5.3 Kernel::terminate() Not Using try/finally [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. `Kernel::terminate()` now wraps `$this->persistCache->persist()` in a `try` block with a `finally` block that calls `parent::terminate()`. This ensures that the Symfony kernel termination always runs, even if the cache persistence throws an exception.
---
## 6. Frontend
### 6.1 document.write() in Login Script [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. The `document.open(); document.write(html); document.close();` pattern in `_script.html.twig` has been replaced with `document.documentElement.innerHTML = html;`. This avoids the deprecated `document.write()` call and is compatible with the Content-Security-Policy now set by `SecurityHeadersListener`.
### 6.2 No Input Sanitization in Username Echo [LOW PRIORITY] ⬜ Open
**Current state:** In `login.html.twig`, the username is echoed back into the input value: `value="{{ username }}"`. The username comes from the sanitized `makeCacheKey()` output, which restricts to `[A-Za-z0-9_.]`, so HTML injection is not possible with the current sanitization. Twig's auto-escaping is also on by default.
**Recommendation:** Add Twig's `escape` filter explicitly for defense-in-depth: `value="{{ username|e('html_attr') }}"`. Also consider whether the `message` variable in `<p id="preauth-message">{{ message|default }}</p>` could ever contain user input.
**Why:** While the current sanitization prevents XSS, relying on `makeCacheKey()` for HTML safety is an implicit coupling between cache key logic and output safety. If `makeCacheKey()` were ever relaxed to allow more characters, the template would become vulnerable. Twig auto-escaping handles HTML body context, but `html_attr` escaping is more appropriate for attribute contexts.
---
## 7. Configuration
### 7.1 No Validation of Environment Variables [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. Environment variables in `config/services.yaml` now use Symfony's env var processors for type casting:
- `app.cookie_ttl: '%env(int:COOKIE_TTL)%'`
- `app.subdomain_redirect: '%env(bool:SUBDOMAIN_REDIRECT)%'`
- `app.ip_ttl: '%env(int:IP_TTL)%'`
- `app.teapot: '%env(bool:TEAPOT)%'`
This ensures invalid values fail fast at container compilation rather than at runtime with a confusing type error. The `rate_limiter.yaml` already used `%env(int:...)%` — this pattern is now applied consistently.
### 7.2 APP_SECRET Not Used Meaningfully [LOW PRIORITY] ✅ Addressed (Documented)
**Current state:** `APP_SECRET` is configured in `framework.yaml` and is required by Symfony. Preauth doesn't use Symfony sessions (now explicitly disabled), CSRF tokens, or signed cookies — the main uses of `APP_SECRET`. The README now documents that `APP_SECRET` is a Symfony requirement and that session cookies are random ULIDs looked up in cache, not signed tokens. The hardcoded `APP_SECRET` in `bin/franken.sh` has also been removed.
---
## 8. Documentation
### 8.1 Missing Security Model Documentation [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Addressed. The README now includes a comprehensive "Security Model" section under "Architecture" that covers:
- Cookie security attributes (`__Host-` prefix, `SameSite=Strict`, `Secure`, `HttpOnly`)
- Nonce system (15-byte random, single-use, 120s TTL)
- TOTP verification window (±1 period / ±30 seconds)
- Backup codes (case-insensitive, single-use, alphanumeric)
- Rate limiting (per-IP, compound sliding window, cannot be disabled)
- Security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS)
A dedicated `docs/SECURITY.md` with the full threat model and `Remote-User` guidance (see item 1.2) could still be valuable as a standalone document.
### 8.2 Missing CHANGELOG.md [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. A `CHANGELOG.md` has been created following the [Keep a Changelog](https://keepachangelog.com/) format, with full version history from v0.0.1 through the unreleased v1.0 changes. The version history was previously inline in the README.
### 8.3 Missing CONTRIBUTING.md [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. A `CONTRIBUTING.md` has been created with development setup instructions, code style guidelines, testing requirements, PR process, commit message conventions, and architecture overview.
### 8.4 Stale Branch References in ROADMAP [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. The ROADMAP's branch status table has been updated to reflect that all feature branches have been pruned and development uses a feature-branch + PR workflow into `main`. Completed security review items are now checked off, and the TOTP leeway description has been updated from "10-second leeway" to "±1 period leeway (±30 seconds)".
---
## 9. CI & Workflows
### 9.1 Inconsistent Tag Format [MEDIUM PRIORITY] ✅ Addressed
**Current state:** Fixed. Git tags are now standardized on the `v` prefix (e.g., `v1.0.0` instead of `1.0.0`). The Docker workflow (`docker.yaml`) now triggers on `v*.*.*` tag patterns and includes a step to extract the version number without the `v` prefix for the Docker image tag. The existing un-prefixed tags (`0.7.0` through `0.10.0`) remain in the repository but all future releases will use the `v` prefix.
### 9.2 Stale develop Branch in CI Triggers [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. The `tests.yaml` and `develop.yaml` workflows no longer reference the `develop` branch, which has been pruned. CI now triggers on `main` only (for push) and `main` only (for pull requests).
### 9.3 publish.yaml Fails on Re-run [LOW PRIORITY] ✅ Addressed
**Current state:** Fixed. The GitHub sync workflow (`publish.yaml`) now uses `git remote add ... 2>/dev/null || git remote set-url ...` instead of bare `git remote add`, which would fail if the remote already existed from a previous run.
---
## What's Done Well
- **Listener-based architecture** is a good fit for this use case — each listener has a single responsibility, and the priority chain creates a clear request processing pipeline.
- **Cookie security** is excellent: `__Host-` prefix, `Secure`, `HttpOnly`, `SameSite=Strict`, and a separate non-prefixed cookie for domain-scoped central auth. Cookie name and domain selection logic is now shared via `CookieNameTrait::sessionCookieName()` and `sessionCookieDomain()`.
- **Nonce-based replay protection** with single-use, TTL-limited nonces and collision retry is well-designed. The nonce also serves as CSRF protection for the POST form path.
- **Rate limiting** with compound sliding windows (burst + sustained) and the humorous teapot option is practical and well-implemented.
- **Test suite** is exemplary: 100% coverage, good use of test helpers, functional tests that exercise the full kernel, and edge cases like ULID collisions and nonce reuse.
- **Dual-layer cache** (APCu + filesystem with change tracking) is a clever solution for persistence without a database.
- **Backup code system** with single-use enforcement, case-insensitivity, and audit trail (keeping consumed codes with `false` value) is well thought out. Backup code values are no longer logged.
- **Interfaces** (`LoginInterface`, `DomainInterface`, `BackupCodeInterface`) enable clean mocking in tests. All now have `declare(strict_types=1)`.
- **FrankenPHP worker mode** via the Caddyfile and Dockerfile is a modern, performant serving strategy.
- **Security headers** are now set on all responses via `SecurityHeadersListener`.
- **Error handling** in cache-dependent listeners now fails closed (denies access on cache errors) rather than propagating 500 errors.
---
*Originally prepared as a design review. Updated to reflect the state of the `fix/v1.0-must-fix` branch.*
+4 -1
View File
@@ -33,9 +33,12 @@ RUN composer dump-env prod --empty
# start creating final image
FROM dunglas/frankenphp:php8.5-trixie
# install APCu
# install APCu and curl (needed for healthcheck)
RUN pecl install apcu && \
docker-php-ext-enable apcu
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# symfony required environment variables
ENV APP_DEBUG=0
+487
View File
@@ -0,0 +1,487 @@
# Preauth — Project Roadmap
## Project Overview
Preauth is a pre-authentication gate for self-hosted services. It sits
between a reverse proxy (Caddy's `forward_auth`) and your web service,
requiring a TOTP code (or backup code) before traffic ever reaches the
protected application. It is **not** a replacement for the service's own
authentication — it's a gate that prevents outsiders from even seeing
what service is running.
- **Location:** `projects/preauth/`
- **Framework:** Symfony 7.4 (PHP ≥ 8.4)
- **Serving:** FrankenPHP (Docker image)
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
- **Auth:** TOTP (single secret) + single-use backup codes
- **Production status:** Running in production since June 2024
### Current Production Use
| Service | Purpose |
|-------------|--------------------------------------------------|
| Bitwarden | Password manager — always accessible, invisible to the world |
| Microbin | Sharing text blobs and small files across devices |
| Gitea | Code hosting — some DNS configs must be public |
---
## Architecture
### Request Flow
```
Client → Caddy → forward_auth → Preauth listeners (priority order) → 200/401/418
```
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
If found → `200 OK` + `Remote-User` header → Caddy proxies to backend.
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
valid IP-based session. If found → `200 OK` + `Remote-User`.
3. **PublicAccessListener** (priority 84) — If `PUBLIC_PATHS` is
configured and the request matches a public path pattern, applies
per-IP rate limiting. Within limit → `200 OK`. Over limit → `429`.
Authenticated users never reach this listener.
4. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
exceeded login attempt threshold → `418 I'm a Teapot` (or `429`).
5. **LoginListener** (priority 66) — Detects login attempts via
`X-Preauth` header (base64url JSON) or POST form on auth subdomain.
Validates TOTP/backup codes through `LoginManager`.
6. **InterceptListener** (priority 55) — Fallback: if no listener has
set a response, either redirects to auth subdomain (central auth) or
renders the Twig login page with a fresh nonce.
### Key Design Decisions
- **No controllers** — Entirely event-listener-driven. Clean separation
of concerns, each listener handles one stage of the auth flow.
- **Dual-layer cache** — APCu for fast in-memory lookups, file-based
storage for persistence across container restarts. `MonitorCacheKeys`
wraps the PSR-6 pool to track key changes for efficient persistence
(only write what changed).
- **`__Host-` prefixed cookies** — `SameSite=Strict`, `Secure`,
`HttpOnly`. Central auth mode uses a separate `__Http-Domain-Preauth`
cookie name (domain-scoped, no `__Host-` prefix).
- **Nonce system** — 15-byte random nonces, single-use, 120s TTL, with
retry-on-collision (up to 3 attempts).
- **TOTP with ±1 period leeway (±30 seconds)** — Accommodates clock drift.
- **Backup codes** — Case-insensitive alphanumeric, single-use, stored
in cache with year-2999 expiry. Generated via console command.
- **Domain awareness** — `DomainManager` handles multi-part TLDs
(`.co.uk`, `.com.au`, etc.) with a built-in TLD lookup table.
- **Interfaces** — `LoginInterface`, `DomainInterface`,
`BackupCodeInterface` extracted to support testing (mockable).
---
## Test Suite Status
### Current Results
| Metric | Value |
|--------------|--------------------------------|
| **Tests** | 293 |
| **Assertions** | 605 |
| **Pass** | 222 (100%) |
| **Fail** | 0 |
| **Errors** | 0 |
| **Warnings** | 0 |
| **Time** | ~0.56s (without coverage) |
| | ~1.31s (with coverage) |
### Code Coverage
| Metric | Percentage |
|----------|---------------------|
| **Lines** | **100.00%** (442/442) |
| **Methods** | **100.00%** (83/83) |
| **Classes** | **100.00%** (21/21) |
Every class, method, and line in `src/` is covered.
### Source → Test Mapping
| Source File | Test File | Type |
|------------------------------------------|----------------------------------------------------|----------|
| `Clock.php` | `Unit/ClockTest.php` | Unit |
| `ConfigBag.php` | `Unit/ConfigBagTest.php` | Unit |
| `Kernel.php` | (covered via functional tests) | Functional |
| `MonitorCacheKeys.php` | `Unit/MonitorCacheKeysTest.php` | Unit |
| `PersistCache.php` | `Unit/PersistCacheTest.php` | Unit |
| `Utilities.php` | `Unit/UtilitiesTest.php` | Unit |
| `Command/GenerateBackupCodesCommand.php` | `Unit/Command/GenerateBackupCodesCommandTest.php` | Unit |
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.php` | Unit |
| `Listener/AcceptListener.php` | `Unit/Listener/AcceptListenerTest.php` | Unit |
| `Listener/PublicAccessListener.php` | `Unit/Listener/PublicAccessListenerTest.php` | Unit |
| `Listener/AllowListener.php` | `Unit/Listener/AllowListenerTest.php` | Unit |
| `Listener/InterceptListener.php` | `Unit/Listener/InterceptListenerTest.php` | Unit |
| `Listener/LoginListener.php` | `Unit/Listener/LoginListenerTest.php` | Unit |
| `Listener/RejectListener.php` | `Unit/Listener/RejectListenerTest.php` | Unit |
| `Service/BackupCodeManager.php` | `Unit/Service/BackupCodeManagerTest.php` | Unit |
| `Service/DomainManager.php` | `Unit/Service/DomainManagerTest.php` | Unit |
| `Service/PublicPathMatcher.php` | `Unit/Service/PublicPathMatcherTest.php` | Unit |
| `Service/LoginManager.php` | `Unit/Service/LoginManagerTest.php` | Unit |
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.php` | Unit |
| `Trait/GetTotpTrait.php` | `Unit/Trait/GetTotpTraitTest.php` | Unit |
| `Trait/HasLoggerTrait.php` | `Unit/Trait/HasLoggerTraitTest.php` | Unit |
| `Trait/MakeNonceTrait.php` | `Unit/Trait/MakeNonceTraitTest.php` | Unit |
| `Trait/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
| *(Public access flow)* | `Functional/PublicAccessFlowTest.php` | Functional |
### Test Quality Assessment
**Strengths:**
- **100% coverage** — every line, method, and class.
- **Well-structured test hierarchy** — Unit tests per class, functional
tests for the full HTTP kernel flow. Two support traits
(`TotpTestHelper`, `ListenerTestHelper`) provide reusable fixtures
(frozen clock, deterministic TOTP, Twig environment, mock rate
limiters).
- **Edge cases well-covered** — ULID collision handling, nonce collision
retries, spent nonces, invalid payloads (bad base64, non-object JSON,
arrays, null, booleans), empty/whitespace fields, field truncation,
multibyte characters in cache keys, multi-part TLD domain matching,
cookie pruning on invalid sessions.
- **Both positive and negative paths** — Every listener tests both
success and failure scenarios.
- **Security-conscious testing** — Backup code single-use enforcement,
case-insensitivity, character stripping, rate limit teapot vs.
too-many-requests, return URL validation (prevents open redirect),
cookie security attributes.
- **Realistic functional tests** — `AuthenticationFlowTest` goes through
the actual Symfony kernel: fetches nonces from rendered HTML, submits
TOTP codes, verifies cookies are set, tests the full login →
authenticated access cycle.
- **Smart test infrastructure** — `KernelBrowser::disableReboot()` used
in functional tests so nonces persist across requests (matching
production APCu behavior).
**Status: Test suite goal is met.** 222 tests, 100% coverage, all passing.
---
## Roadmap
### Phase 1 — Public but Rate-Limited Access ✅ Completed (v1.1)
**Goal:** Allow select services to be publicly accessible (no TOTP
required) but with aggressive per-IP rate limiting to prevent bot
traffic from overwhelming the server.
**Context:** The user previously made Gitea semi-public (view but no
login), but bot traffic slowed the server and consumed all household
bandwidth, forcing it back to fully private. The solution isn't more
authentication — it's bandwidth/resource protection for public-facing
services.
**Implementation:**
- New config variables:
- `PUBLIC_PATHS` — Comma-separated path patterns with `*` (single
segment) and `**` (cross-segment) wildcard support. Optional host
prefix (e.g., `code.example.com/public/**`). When empty (default),
the feature is fully disabled.
- `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` — Burst rate limiting
(default: 100 requests per 60 seconds).
- `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` — Sustained rate limiting
(default: 500 requests per 3600 seconds).
- New listener: **PublicAccessListener** (priority 84, after
AcceptListener and AllowListener, before RejectListener):
- Checks if the request path matches a configured public path pattern.
- If public and within rate limit → `200 OK` (no `Remote-User` header).
- If public and over rate limit → `429 Too Many Requests` with
`Retry-After` header.
- Authenticated users bypass this listener entirely (AcceptListener
or AllowListener returns 200 first).
- New service: **PublicPathMatcher** — Parses path patterns and matches
request paths with wildcard support.
- Separate `public_limiter` compound rate limiter (independent from
the login attempt rate limiter).
- [x] Design public path detection mechanism (path-based with wildcards)
- [x] Implement `PublicAccessListener` with separate rate limiter pool
- [x] Add config variables and defaults
- [x] Update Caddyfile example with public service snippet
- [x] Tests for public mode (within limit, over limit, burst behavior)
- [x] Documentation in README
### Phase 2 — Session Management & Audit
**Goal:** Give visibility into who has access and when it was granted.
- [ ] **Active sessions view** — Console command or simple API endpoint
to list active sessions (cookie-based and IP-based), showing:
- Session ID / username
- IP address
- First auth timestamp
- Last seen timestamp
- Scope (cookie vs. IP)
- [ ] **Session revocation** — Console command to revoke a specific
session by ID or revoke all sessions for an IP.
- [ ] **Audit log** — Log every successful and failed authentication
attempt to a persistent store (file-based JSONL, similar to the email
integration's audit log):
```json
{
"timestamp": "2025-01-15T14:23:01Z",
"ip": "192.168.1.50",
"action": "login_success",
"username": "mom",
"method": "totp"
}
```
- [ ] Tests for all new commands and endpoints
### Phase 2b — Backup Code System Completion
**Goal:** Finish the backup code system — the core logic is solid but
the management surface is incomplete.
**What already exists:**
- ✅ `BackupCodeManager::generate()` — Creates codes, saves to cache
with year-2999 expiry
- ✅ `BackupCodeManager::expire()` — Deletes all `backup_` prefixed
keys from cache
- ✅ `BackupCodeManager::verifyAndConsume()` — Validates and marks code
as used (sets value to `false`, keeps the key for audit trail)
- ✅ `app:generate-backup-codes [count]` console command
- ✅ Tests for all of the above (100% coverage)
**What's missing:**
- [ ] **`app:list-backup-codes` command** — Show backup code status:
- Total codes generated
- How many are still valid (unused)
- How many have been spent (and optionally when)
- Output format: table with status column (✅ valid / ⛔ used)
- Note: spent codes are kept in cache with value `false`, so we can
distinguish "used" from "never existed" — this is good design
- [ ] **`app:expire-backup-codes` command** — Wrap the existing
`BackupCodeManager::expire()` method in a console command. Should:
- Show how many codes are being expired before confirmation
- Support `--force` flag to skip confirmation prompt
- Call `persistCache->boot()` and `persistCache->persist()` like the
generate command does (since `Kernel::terminate()` doesn't run in
CLI)
- [ ] **Notification on backup code use** — When
`verifyAndConsume()` consumes a backup code, fire a notification
through configurable channels:
- Discord webhook (we already have the `discord.sh` infrastructure)
- ntfy
- Email (once email integration is available)
- Webhook (generic HTTP POST for future integrations)
- Config variables:
- `BACKUP_CODE_NOTIFY=discord,ntfy` — comma-separated channels
- `BACKUP_CODE_NOTIFY_WEBHOOK=''` — generic webhook URL
- Message should include: timestamp, IP address, username, and how
many valid codes remain
- Architecture: `BackupCodeManager` dispatches an event
(e.g. `BackupCodeUsedEvent`) after consuming a code. A listener
handles the notification dispatch. This keeps the notification
logic out of the backup code manager itself.
- [ ] **Low-codes warning** — If backup codes fall below a threshold
(e.g. 3 remaining), include a warning in the notification and/or
surface it in the `list-backup-codes` command output
- [ ] Tests for all new commands and notification dispatch
### Phase 2c — Passkey Authentication
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
authentication method alongside TOTP and backup codes.
**Context:** Passkeys are the modern standard for passwordless auth.
They're phishing-resistant (domain-bound), use biometrics or device
PINs, and are significantly more user-friendly than typing 6-digit
codes. For a pre-auth gate that friends and family use, passkeys would
be a major UX improvement — especially for non-technical users who
struggle with TOTP apps.
**Design considerations:**
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
secret shared with all devices), each device registers its own
passkey. This is actually better for a family-use gate — you can
register mom's phone separately from dad's laptop.
- WebAuthn requires a **challenge-response flow**:
1. Client requests a challenge (preauth generates and stores a
challenge nonce, similar to the existing nonce system)
2. Browser prompts for biometric/PIN, creates a signed assertion
3. Server verifies the assertion against the registered credential
- This is a **two-step flow** unlike TOTP's single-step, which means
the login page JS and `LoginListener` need to handle an additional
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
is a good foundation — extend it with a "use passkey" button that
initiates the `navigator.credentials.get()` flow.
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
Symfony bundle available). Would add registration ceremony (console
command or initial-setup flow to register a passkey).
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
7.4 and FrankenPHP
- [ ] Design passkey registration flow (console command? first-visit
setup? separate registration endpoint?)
- [ ] Implement challenge generation and storage (extend existing
nonce/cache infrastructure)
- [ ] Implement assertion verification in a new `PasskeyManager`
service (implements a shared `AuthMethodInterface`?)
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
- [ ] Handle multiple registered passkeys (per-device)
- [ ] Console command: `app:list-passkeys` — show registered devices
- [ ] Console command: `app:remove-passkey` — revoke a passkey
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
- [ ] Tests for registration, authentication, and revocation
- [ ] Consider: should passkeys be a *replacement* for TOTP or an
*alternative*? (Probably alternative — keep TOTP as fallback)
### Phase 3 — Multi-User Support
**Goal:** Support multiple TOTP users for household/family access.
*Note: This is a significant feature that changes the single-secret
model. It should only be pursued if the single-secret + backup codes
approach proves insufficient for the use case.*
- [ ] Multiple TOTP secrets, each with a label (e.g., "mom", "dad",
"friend")
- [ ] Per-user backup codes
- [ ] Per-user session tracking (the `username` field in Payload already
supports this — sessions are already tagged with an ID)
- [ ] Console command to add/remove/list users
- [ ] Consider: should the login page ask for a username, or should all
TOTP codes be tried against all secrets? (Username is better —
it's already in the payload.)
- [ ] Tests for multi-user scenarios
### Phase 4 — Polish & Hardening
**Goal:** Production hardening and quality-of-life improvements.
- [ ] **Docker image improvements:**
- Multi-arch builds (amd64 + arm64 for Raspberry Pi)
- Smaller image size (alpine-based if feasible)
- Better health check (actual endpoint, not just `curl localhost`)
- [ ] **GitHub/Gitea repository polish:**
- ✅ Comprehensive README with setup guide, architecture overview, and
configuration reference
- Contributing guidelines
- ✅ Changelog formalised (CHANGELOG.md)
- ✅ CI workflows (tests + php-cs-fixer on push/PR, Docker image on tag)
- [ ] **Security review:**
- ✅ CSRF protection on the POST form login — nonce system documented
- ✅ Security headers added (X-Content-Type-Options, X-Frame-Options, CSP, etc.)
- Review nonce entropy and cache key collision space
- Consider session fixation protections
- [ ] **Frontend improvements:**
- Mobile-responsive login page audit
- Accessibility audit (ARIA labels, keyboard navigation)
- Dark mode (if not already — the teal background suggests it might
already be dark-themed)
- [ ] **Logging improvements:**
- Structured logging (JSON format option) for easier parsing
- Log rotation configuration
- Debug mode documentation
---
## Feature Thoughts
Based on the review, here are features that might be missing or worth
considering, keeping in mind that preauth is a **gate**, not a full
identity provider:
### High Value
1. **Public but rate-limited mode** (Phase 1) — Directly solves the
Gitea bot traffic problem. This is the most impactful missing
feature.
2. **Passkey authentication** (Phase 2c) — Phishing-resistant,
passwordless auth that's far more user-friendly than TOTP for
non-technical family members. The modern standard for this kind
of gate.
3. **Backup code notifications** (Phase 2b) — When a backup code is
used, you should know about it immediately. This is a security-critical
event — it means someone lost their device or is locked out of their
TOTP app. Discord/ntfy/email notification should fire automatically.
4. **Backup code management commands** (Phase 2b) — The `generate`
command exists, but `list` and `expire` commands are missing despite
the underlying methods (`expire()`) already being implemented.
5. **Session visibility and revocation** (Phase 2) — Currently there's
no way to see who has access or revoke a session without clearing
the entire cache. For a security tool, this is important.
6. **Audit log** (Phase 2) — For a security gate, not having an audit
trail of logins (successful and failed) is a gap. The data is logged
at debug level, but not persisted in a queryable format.
### Medium Value
4. **Health check endpoint** — The Dockerfile has a `HEALTHCHECK` that
just `curl`s localhost, but a dedicated `/health` endpoint that
verifies cache connectivity would be more meaningful.
5. **Graceful degradation** — If the file-based cache is corrupted or
unavailable, does preauth fail open or closed? Should be documented
and tested. (Currently the `PersistCache` handles this in `boot()`,
but edge cases around partial corruption could be explored.)
6. **Rate limit headers** — Adding `X-RateLimit-Remaining` and
`Retry-After` headers to rate-limited responses would help legitimate
clients back off gracefully.
### Lower Value (Nice to Have)
7. **WebSocket support** — If protected services use WebSocket
connections, does `forward_auth` handle the upgrade handshake? This
is likely a Caddy configuration concern, but worth documenting.
8. **Theming presets** — Beyond the current env-var colour config,
preset themes or custom CSS upload could be nice for personalisation.
9. **TOTP secret rotation** — Console command to generate a new TOTP
secret and invalidate all existing sessions. Useful if a device is
lost or compromised.
10. **Per-service authentication policies** — Different services could
require different authentication strength (e.g., Bitwarden requires
TOTP + recent login, Microbin accepts any valid session). This would
need Caddy configuration support to pass the policy to preauth.
---
## Branch Status
| Branch | Status | Notes |
|--------|--------|-------|
| `main` (0.10.0) | Production | Current stable release |
All feature branches have been pruned. Development uses a feature-branch + PR workflow into `main`.
---
## Relationship to Other Projects
| Project | Integration |
|---------|-------------|
| MCP server | Preauth could be registered as an MCP command for session management ("revoke all sessions", "who's logged in?") |
| Email integration | Audit log entries could be included in morning summary ("2 failed login attempts from 203.0.113.50 overnight") |
| Discord/ntfy | Alert on backup code usage, suspicious activity (rate limit triggered, multiple failed attempts from new IP), low backup code count |
---
*Prepared by Lyra, your office-side assistant. ✨*
Executable
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# PreAuth Dev Server Script
#
# Manages a local PHP dev server for end-to-end development and testing.
# Binds to 0.0.0.0 so the app is accessible via a reverse proxy (Caddy) for
# browser-based visual verification.
#
# PreAuth is a TOTP-based authentication gateway. It uses APCu for nonce/cache
# and filesystem for session persistence — no database needed. The dev server
# runs with APP_ENV=dev and APP_DEBUG=1 for live troubleshooting.
#
# Self-bootstrapping: the `start` command checks for required system packages
# (PHP, extensions, tools), Composer, and project dependencies — installing
# them automatically if missing. This means the script works even after a
# terminal reset/reboot, embracing the self-cleaning container design.
#
# Usage:
# bin/dev.sh start Start the dev server (auto-installs deps if needed)
# bin/dev.sh stop Stop the dev server
# bin/dev.sh status Check if the dev server is running
# bin/dev.sh restart Stop and start the dev server
#
# Port assignment (P-R-E = 7-7-3):
# 8773 → https://preauth.lyra-dev.devgnome.com
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Configuration ───────────────────────────────────────────────────────────
PORT=8773
HOST="0.0.0.0"
ENV="dev"
DEV_SECRET="dev_secret_not_for_production_use_only"
PID_FILE="var/.dev-server.pid"
LOG_FILE="var/log/dev-server.log"
# Required PHP extensions (checked via php -m)
REQUIRED_PHP_EXTS=(
ctype
iconv
mbstring
apcu
dom
SimpleXML
xml
)
# Apt packages for PHP + extensions
# Note: preauth uses Symfony 7.4 which requires PHP >=8.1.
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
PHP_APT_PACKAGES=(
php8.4-cli
php8.4-common # ctype, iconv
php8.4-mbstring
php8.4-xml # dom, SimpleXML, xml
php8.4-opcache
php8.4-readline
php8.4-apcu # APCu — critical for nonce cache, rate limiter, sessions
)
# System tools needed
SYSTEM_TOOLS=(
git
unzip
curl
)
# Resolve project root (script lives in bin/)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Ensure var directory structure exists
mkdir -p var/log var/share
# ── Helpers ─────────────────────────────────────────────────────────────────
is_running() {
if [[ ! -f "$PID_FILE" ]]; then
return 1
fi
local pid
pid="$(cat "$PID_FILE")"
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
return 0
}
print_status() {
if is_running; then
local pid
pid="$(cat "$PID_FILE")"
echo "✅ PreAuth dev server is RUNNING"
echo " PID: $pid"
echo " URL: http://localhost:${PORT}"
echo " Exposed: http://${HOST}:${PORT}"
echo " Dev URL: https://preauth.lyra-dev.devgnome.com"
echo " Logs: ${LOG_FILE}"
else
echo "⛔ PreAuth dev server is STOPPED"
fi
}
# ── Bootstrap ───────────────────────────────────────────────────────────────
# Ensures all system packages, Composer, and project dependencies are present.
# Idempotent — if everything is already installed, checks are fast no-ops.
# This is what makes the script survive terminal resets/reboots.
bootstrap() {
local needed_packages=()
# ── Check system tools ──
for tool in "${SYSTEM_TOOLS[@]}"; do
if ! command -v "$tool" &>/dev/null; then
needed_packages+=("$tool")
fi
done
# ── Check PHP and required extensions ──
local php_needs_install=false
if ! command -v php &>/dev/null; then
php_needs_install=true
else
for ext in "${REQUIRED_PHP_EXTS[@]}"; do
if ! php -m 2>/dev/null | grep -iq "^${ext}$"; then
php_needs_install=true
break
fi
done
fi
if [[ "$php_needs_install" == "true" ]]; then
needed_packages+=("${PHP_APT_PACKAGES[@]}")
fi
# ── Install missing packages ──
if [[ ${#needed_packages[@]} -gt 0 ]]; then
echo "→ Installing missing system packages: ${needed_packages[*]}"
sudo apt-get update -qq
sudo apt-get install -y -qq "${needed_packages[@]}"
fi
# ── Ensure APCu is enabled for CLI ──
# PreAuth's console commands need APCu; the Dockerfile sets apc.enable_cli=1
local apcu_ini="/etc/php/8.4/mods-available/apcu.ini"
if [[ -f "$apcu_ini" ]] && ! grep -q 'apc.enable_cli' "$apcu_ini" 2>/dev/null; then
echo "→ Enabling APCu CLI support…"
echo 'apc.enable_cli=1' | sudo tee -a "$apcu_ini" >/dev/null
fi
# ── Ensure Composer is available ──
if ! command -v composer &>/dev/null; then
echo "→ Installing Composer…"
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
fi
# ── Ensure project dependencies are installed ──
if [[ ! -d "vendor/" ]]; then
echo "→ Installing Composer dependencies…"
APP_ENV=dev composer install --no-interaction
fi
}
# ── Commands ────────────────────────────────────────────────────────────────
start() {
if is_running; then
echo "⚠️ Dev server is already running (PID $(cat "$PID_FILE"))"
print_status
exit 0
fi
echo "→ Starting PreAuth dev server on ${HOST}:${PORT}"
# Self-bootstrap: ensure all dependencies are present
bootstrap
echo "→ Clearing dev cache…"
APP_ENV="$ENV" \
APP_DEBUG=1 \
APP_SECRET="$DEV_SECRET" \
php bin/console cache:clear 2>&1 | tail -3
echo "→ Starting PHP dev server…"
APP_ENV="$ENV" \
APP_DEBUG=1 \
APP_SECRET="$DEV_SECRET" \
APP_SHARE_DIR="${PROJECT_ROOT}/var/share" \
nohup php -S "${HOST}:${PORT}" -t public/ > "$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
# Give it a moment to boot
sleep 2
if is_running; then
echo ""
print_status
else
echo "❌ Failed to start dev server. Check logs:"
echo " ${LOG_FILE}"
tail -20 "$LOG_FILE" 2>/dev/null || true
rm -f "$PID_FILE"
exit 1
fi
}
stop() {
if ! is_running; then
echo "⚠️ Dev server is not running."
rm -f "$PID_FILE"
exit 0
fi
local pid
pid="$(cat "$PID_FILE")"
echo "→ Stopping dev server (PID ${pid})…"
kill "$pid" 2>/dev/null || true
# Wait for graceful shutdown
local count=0
while kill -0 "$pid" 2>/dev/null && [[ $count -lt 10 ]]; do
sleep 0.5
count=$((count + 1))
done
# Force kill if still alive
if kill -0 "$pid" 2>/dev/null; then
echo "→ Process didn't exit gracefully, sending SIGKILL…"
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$PID_FILE"
echo "✅ Dev server stopped."
}
restart() {
stop
sleep 1
start
}
# ── Main ────────────────────────────────────────────────────────────────────
usage() {
echo "Usage: bin/dev.sh {start|stop|status|restart}"
echo ""
echo "Commands:"
echo " start Start the dev server (auto-installs deps if needed)"
echo " stop Stop the dev server"
echo " status Check if the dev server is running"
echo " restart Restart the dev server"
exit 1
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) print_status ;;
restart) restart ;;
*) usage ;;
esac
+7 -4
View File
@@ -1,13 +1,16 @@
#!/bin/sh
# Dev utility — builds and runs the preauth container locally.
# Not for production use.
# APP_SECRET should be set in your environment or .env file.
docker container rm preauth
docker build . -t digtialadapt/preauth:dev
docker container rm preauth 2>/dev/null
docker build . -t digitaladapt/preauth:dev
docker run --name preauth \
-e APP_ENV=dev \
-e APP_DEBUG=true \
-e APP_SECRET=f88a1074691c40415be4439345b79f69 \
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
-e APP_SHARE_DIR=var/share \
-e DEFAULT_URI=http://localhost \
-v ./var/share:/app/var/share \
-p 8000:80 \
digtialadapt/preauth:dev
digitaladapt/preauth:dev
+1
View File
@@ -75,6 +75,7 @@
}
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpunit/phpunit": "^13.2",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*"
Generated
+1304 -2
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,6 +10,8 @@ framework:
adapters: cache.adapter.apcu
sessionStorage:
adapters: cache.adapter.filesystem
publicRateLimitCache:
adapters: cache.adapter.apcu
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: digitaladapt/preauth
+3 -2
View File
@@ -5,5 +5,6 @@ framework:
trusted_proxies: 'private_ranges'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto']
# Note that the session will be started ONLY if you read or write from it.
session: true
# Sessions are disabled — preauth implements its own cookie/cache-based
# session management and does not use Symfony's session subsystem.
session: false
+14
View File
@@ -13,3 +13,17 @@ framework:
login_limiter:
policy: compound
limiters: [burst, upper]
public_burst:
policy: 'sliding_window'
limit: '%env(int:PUBLIC_BURST_COUNT)%'
interval: '%env(int:PUBLIC_BURST_TIME)% seconds'
cache_pool: 'publicRateLimitCache'
public_upper:
policy: 'sliding_window'
limit: '%env(int:PUBLIC_UPPER_COUNT)%'
interval: '%env(int:PUBLIC_UPPER_TIME)% seconds'
cache_pool: 'publicRateLimitCache'
public_limiter:
policy: compound
limiters: [public_burst, public_upper]
+2
View File
@@ -10,3 +10,5 @@ framework:
adapters: cache.adapter.array
sessionStorage:
adapters: cache.adapter.array
publicRateLimitCache:
adapters: cache.adapter.array
-844
View File
@@ -1,844 +0,0 @@
<?php
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\Component\Config\Loader\ParamConfigurator as Param;
/**
* This class provides array-shapes for configuring the services and bundles of an application.
*
* Services declared with the config() method below are autowired and autoconfigured by default.
*
* This is for apps only. Bundles SHOULD NOT use it.
*
* Example:
*
* ```php
* // config/services.php
* namespace Symfony\Component\DependencyInjection\Loader\Configurator;
*
* return App::config([
* 'services' => [
* 'App\\' => [
* 'resource' => '../src/',
* ],
* ],
* ]);
* ```
*
* @psalm-type ImportsConfig = list<string|array{
* resource: string,
* type?: string|null,
* ignore_errors?: bool,
* }>
* @psalm-type ParametersConfig = array<string, scalar|\UnitEnum|array<scalar|\UnitEnum|array<mixed>|Param|null>|Param|null>
* @psalm-type ArgumentsType = list<mixed>|array<string, mixed>
* @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}
* @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key
* @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator
* @psalm-type DeprecationType = array{package: string, version: string, message?: string}
* @psalm-type DefaultsType = array{
* public?: bool,
* tags?: TagsType,
* resource_tags?: TagsType,
* autowire?: bool,
* autoconfigure?: bool,
* bind?: array<string, mixed>,
* }
* @psalm-type InstanceofType = array{
* shared?: bool,
* lazy?: bool|string,
* public?: bool,
* properties?: array<string, mixed>,
* configurator?: CallbackType,
* calls?: list<CallType>,
* tags?: TagsType,
* resource_tags?: TagsType,
* autowire?: bool,
* bind?: array<string, mixed>,
* constructor?: string,
* }
* @psalm-type DefinitionType = array{
* class?: string,
* file?: string,
* parent?: string,
* shared?: bool,
* synthetic?: bool,
* lazy?: bool|string,
* public?: bool,
* abstract?: bool,
* deprecated?: DeprecationType,
* factory?: CallbackType,
* configurator?: CallbackType,
* arguments?: ArgumentsType,
* properties?: array<string, mixed>,
* calls?: list<CallType>,
* tags?: TagsType,
* resource_tags?: TagsType,
* decorates?: string,
* decoration_inner_name?: string,
* decoration_priority?: int,
* decoration_on_invalid?: 'exception'|'ignore'|null,
* autowire?: bool,
* autoconfigure?: bool,
* bind?: array<string, mixed>,
* constructor?: string,
* from_callable?: CallbackType,
* }
* @psalm-type AliasType = string|array{
* alias: string,
* public?: bool,
* deprecated?: DeprecationType,
* }
* @psalm-type PrototypeType = array{
* resource: string,
* namespace?: string,
* exclude?: string|list<string>,
* parent?: string,
* shared?: bool,
* lazy?: bool|string,
* public?: bool,
* abstract?: bool,
* deprecated?: DeprecationType,
* factory?: CallbackType,
* arguments?: ArgumentsType,
* properties?: array<string, mixed>,
* configurator?: CallbackType,
* calls?: list<CallType>,
* tags?: TagsType,
* resource_tags?: TagsType,
* autowire?: bool,
* autoconfigure?: bool,
* bind?: array<string, mixed>,
* constructor?: string,
* }
* @psalm-type StackType = array{
* stack: list<DefinitionType|AliasType|PrototypeType|array<class-string, ArgumentsType|null>>,
* public?: bool,
* deprecated?: DeprecationType,
* }
* @psalm-type ServicesConfig = array{
* _defaults?: DefaultsType,
* _instanceof?: InstanceofType,
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
* }
* @psalm-type ExtensionType = array<string, mixed>
* @psalm-type FrameworkConfig = array{
* secret?: scalar|Param|null,
* http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false
* allowed_http_method_override?: list<string|Param>|null,
* trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%"
* ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%"
* test?: bool|Param,
* default_locale?: scalar|Param|null, // Default: "en"
* set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false
* set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false
* enabled_locales?: list<scalar|Param|null>,
* trusted_hosts?: list<scalar|Param|null>,
* trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"]
* trusted_headers?: list<scalar|Param|null>,
* error_controller?: scalar|Param|null, // Default: "error_controller"
* handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true
* csrf_protection?: bool|array{
* enabled?: scalar|Param|null, // Default: null
* stateless_token_ids?: list<scalar|Param|null>,
* check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false
* cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token"
* },
* form?: bool|array{ // Form configuration
* enabled?: bool|Param, // Default: false
* csrf_protection?: bool|array{
* enabled?: scalar|Param|null, // Default: null
* token_id?: scalar|Param|null, // Default: null
* field_name?: scalar|Param|null, // Default: "_token"
* field_attr?: array<string, scalar|Param|null>,
* },
* },
* http_cache?: bool|array{ // HTTP cache configuration
* enabled?: bool|Param, // Default: false
* debug?: bool|Param, // Default: "%kernel.debug%"
* trace_level?: "none"|"short"|"full"|Param,
* trace_header?: scalar|Param|null,
* default_ttl?: int|Param,
* private_headers?: list<scalar|Param|null>,
* skip_response_headers?: list<scalar|Param|null>,
* allow_reload?: bool|Param,
* allow_revalidate?: bool|Param,
* stale_while_revalidate?: int|Param,
* stale_if_error?: int|Param,
* terminate_on_cache_hit?: bool|Param,
* },
* esi?: bool|array{ // ESI configuration
* enabled?: bool|Param, // Default: false
* },
* ssi?: bool|array{ // SSI configuration
* enabled?: bool|Param, // Default: false
* },
* fragments?: bool|array{ // Fragments configuration
* enabled?: bool|Param, // Default: false
* hinclude_default_template?: scalar|Param|null, // Default: null
* path?: scalar|Param|null, // Default: "/_fragment"
* },
* profiler?: bool|array{ // Profiler configuration
* enabled?: bool|Param, // Default: false
* collect?: bool|Param, // Default: true
* collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null
* only_exceptions?: bool|Param, // Default: false
* only_main_requests?: bool|Param, // Default: false
* dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler"
* collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false
* },
* workflows?: bool|array{
* enabled?: bool|Param, // Default: false
* workflows?: array<string, array{ // Default: []
* audit_trail?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* type?: "workflow"|"state_machine"|Param, // Default: "state_machine"
* marking_store?: array{
* type?: "method"|Param,
* property?: scalar|Param|null,
* service?: scalar|Param|null,
* },
* supports?: list<scalar|Param|null>,
* definition_validators?: list<scalar|Param|null>,
* support_strategy?: scalar|Param|null,
* initial_marking?: list<scalar|Param|null>,
* events_to_dispatch?: list<string|Param>|null,
* places?: list<array{ // Default: []
* name?: scalar|Param|null,
* metadata?: array<string, mixed>,
* }>,
* transitions?: list<array{ // Default: []
* name?: string|Param,
* guard?: string|Param, // An expression to block the transition.
* from?: list<array{ // Default: []
* place?: string|Param,
* weight?: int|Param, // Default: 1
* }>,
* to?: list<array{ // Default: []
* place?: string|Param,
* weight?: int|Param, // Default: 1
* }>,
* weight?: int|Param, // Default: 1
* metadata?: array<string, mixed>,
* }>,
* metadata?: array<string, mixed>,
* }>,
* },
* router?: bool|array{ // Router configuration
* enabled?: bool|Param, // Default: false
* resource?: scalar|Param|null,
* type?: scalar|Param|null,
* cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
* http_port?: scalar|Param|null, // Default: 80
* https_port?: scalar|Param|null, // Default: 443
* strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true
* utf8?: bool|Param, // Default: true
* },
* session?: bool|array{ // Session configuration
* enabled?: bool|Param, // Default: false
* storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native"
* handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null.
* name?: scalar|Param|null,
* cookie_lifetime?: scalar|Param|null,
* cookie_path?: scalar|Param|null,
* cookie_domain?: scalar|Param|null,
* cookie_secure?: true|false|"auto"|Param, // Default: "auto"
* cookie_httponly?: bool|Param, // Default: true
* cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
* use_cookies?: bool|Param,
* gc_divisor?: scalar|Param|null,
* gc_probability?: scalar|Param|null,
* gc_maxlifetime?: scalar|Param|null,
* save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null.
* metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0
* sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
* sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
* },
* request?: bool|array{ // Request configuration
* enabled?: bool|Param, // Default: false
* formats?: array<string, string|list<scalar|Param|null>>,
* },
* assets?: bool|array{ // Assets configuration
* enabled?: bool|Param, // Default: false
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
* version_strategy?: scalar|Param|null, // Default: null
* version?: scalar|Param|null, // Default: null
* version_format?: scalar|Param|null, // Default: "%%s?%%s"
* json_manifest_path?: scalar|Param|null, // Default: null
* base_path?: scalar|Param|null, // Default: ""
* base_urls?: list<scalar|Param|null>,
* packages?: array<string, array{ // Default: []
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
* version_strategy?: scalar|Param|null, // Default: null
* version?: scalar|Param|null,
* version_format?: scalar|Param|null, // Default: null
* json_manifest_path?: scalar|Param|null, // Default: null
* base_path?: scalar|Param|null, // Default: ""
* base_urls?: list<scalar|Param|null>,
* }>,
* },
* asset_mapper?: bool|array{ // Asset Mapper configuration
* enabled?: bool|Param, // Default: false
* paths?: array<string, scalar|Param|null>,
* excluded_patterns?: list<scalar|Param|null>,
* exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true
* server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true
* public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/"
* missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn"
* extensions?: array<string, scalar|Param|null>,
* importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php"
* importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims"
* importmap_script_attributes?: array<string, scalar|Param|null>,
* vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor"
* precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip.
* enabled?: bool|Param, // Default: false
* formats?: list<scalar|Param|null>,
* extensions?: list<scalar|Param|null>,
* },
* },
* translator?: bool|array{ // Translator configuration
* enabled?: bool|Param, // Default: false
* fallbacks?: list<scalar|Param|null>,
* logging?: bool|Param, // Default: false
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
* cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations"
* default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations"
* paths?: list<scalar|Param|null>,
* pseudo_localization?: bool|array{
* enabled?: bool|Param, // Default: false
* accents?: bool|Param, // Default: true
* expansion_factor?: float|Param, // Default: 1.0
* brackets?: bool|Param, // Default: true
* parse_html?: bool|Param, // Default: false
* localizable_html_attributes?: list<scalar|Param|null>,
* },
* providers?: array<string, array{ // Default: []
* dsn?: scalar|Param|null,
* domains?: list<scalar|Param|null>,
* locales?: list<scalar|Param|null>,
* }>,
* globals?: array<string, string|array{ // Default: []
* value?: mixed,
* message?: string|Param,
* parameters?: array<string, scalar|Param|null>,
* domain?: string|Param,
* }>,
* },
* validation?: bool|array{ // Validation configuration
* enabled?: bool|Param, // Default: false
* cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0.
* enable_attributes?: bool|Param, // Default: true
* static_method?: list<scalar|Param|null>,
* translation_domain?: scalar|Param|null, // Default: "validators"
* email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5"
* mapping?: array{
* paths?: list<scalar|Param|null>,
* },
* not_compromised_password?: bool|array{
* enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true
* endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null
* },
* disable_translation?: bool|Param, // Default: false
* auto_mapping?: array<string, array{ // Default: []
* services?: list<scalar|Param|null>,
* }>,
* },
* annotations?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* serializer?: bool|array{ // Serializer configuration
* enabled?: bool|Param, // Default: false
* enable_attributes?: bool|Param, // Default: true
* name_converter?: scalar|Param|null,
* circular_reference_handler?: scalar|Param|null,
* max_depth_handler?: scalar|Param|null,
* mapping?: array{
* paths?: list<scalar|Param|null>,
* },
* default_context?: array<string, mixed>,
* named_serializers?: array<string, array{ // Default: []
* name_converter?: scalar|Param|null,
* default_context?: array<string, mixed>,
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
* }>,
* },
* property_access?: bool|array{ // Property access configuration
* enabled?: bool|Param, // Default: false
* magic_call?: bool|Param, // Default: false
* magic_get?: bool|Param, // Default: true
* magic_set?: bool|Param, // Default: true
* throw_exception_on_invalid_index?: bool|Param, // Default: false
* throw_exception_on_invalid_property_path?: bool|Param, // Default: true
* },
* type_info?: bool|array{ // Type info configuration
* enabled?: bool|Param, // Default: false
* aliases?: array<string, scalar|Param|null>,
* },
* property_info?: bool|array{ // Property info configuration
* enabled?: bool|Param, // Default: false
* with_constructor_extractor?: bool|Param, // Registers the constructor extractor.
* },
* cache?: array{ // Cache configuration
* prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%"
* app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem"
* system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system"
* directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app"
* default_psr6_provider?: scalar|Param|null,
* default_redis_provider?: scalar|Param|null, // Default: "redis://localhost"
* default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost"
* default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost"
* default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection"
* default_pdo_provider?: scalar|Param|null, // Default: null
* pools?: array<string, array{ // Default: []
* adapters?: list<scalar|Param|null>,
* tags?: scalar|Param|null, // Default: null
* public?: bool|Param, // Default: false
* default_lifetime?: scalar|Param|null, // Default lifetime of the pool.
* provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter.
* early_expiration_message_bus?: scalar|Param|null,
* clearer?: scalar|Param|null,
* }>,
* },
* php_errors?: array{ // PHP errors handling configuration
* log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true
* throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true
* },
* exceptions?: array<string, array{ // Default: []
* log_level?: scalar|Param|null, // The level of log message. Null to let Symfony decide. // Default: null
* status_code?: scalar|Param|null, // The status code of the response. Null or 0 to let Symfony decide. // Default: null
* log_channel?: scalar|Param|null, // The channel of log message. Null to let Symfony decide. // Default: null
* }>,
* web_link?: bool|array{ // Web links configuration
* enabled?: bool|Param, // Default: false
* },
* lock?: bool|string|array{ // Lock configuration
* enabled?: bool|Param, // Default: false
* resources?: array<string, string|list<scalar|Param|null>>,
* },
* semaphore?: bool|string|array{ // Semaphore configuration
* enabled?: bool|Param, // Default: false
* resources?: array<string, scalar|Param|null>,
* },
* messenger?: bool|array{ // Messenger configuration
* enabled?: bool|Param, // Default: false
* routing?: array<string, string|array{ // Default: []
* senders?: list<scalar|Param|null>,
* }>,
* serializer?: array{
* default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer"
* symfony_serializer?: array{
* format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json"
* context?: array<string, mixed>,
* },
* },
* transports?: array<string, string|array{ // Default: []
* dsn?: scalar|Param|null,
* serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null
* options?: array<string, mixed>,
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
* retry_strategy?: string|array{
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
* max_retries?: int|Param, // Default: 3
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
* jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1
* },
* rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null
* }>,
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
* stop_worker_on_signals?: list<scalar|Param|null>,
* default_bus?: scalar|Param|null, // Default: null
* buses?: array<string, array{ // Default: {"messenger.bus.default":{"default_middleware":{"enabled":true,"allow_no_handlers":false,"allow_no_senders":true},"middleware":[]}}
* default_middleware?: bool|string|array{
* enabled?: bool|Param, // Default: true
* allow_no_handlers?: bool|Param, // Default: false
* allow_no_senders?: bool|Param, // Default: true
* },
* middleware?: list<string|array{ // Default: []
* id?: scalar|Param|null,
* arguments?: list<mixed>,
* }>,
* }>,
* },
* scheduler?: bool|array{ // Scheduler configuration
* enabled?: bool|Param, // Default: false
* },
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
* enabled?: bool|Param, // Default: false
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array<string, mixed>,
* vars?: array<string, mixed>,
* max_redirects?: int|Param, // The maximum number of redirects to follow.
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
* resolve?: array<string, scalar|Param|null>,
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
* cafile?: scalar|Param|null, // A certificate authority file.
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
* local_pk?: scalar|Param|null, // A private key file.
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
* ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...)
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
* sha1?: mixed,
* pin-sha256?: mixed,
* md5?: mixed,
* },
* crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
* extra?: array<string, mixed>,
* rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null
* caching?: bool|array{ // Caching configuration.
* enabled?: bool|Param, // Default: false
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
* },
* retry_failed?: bool|array{
* enabled?: bool|Param, // Default: false
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
* http_codes?: array<string, array{ // Default: []
* code?: int|Param,
* methods?: list<string|Param>,
* }>,
* max_retries?: int|Param, // Default: 3
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
* },
* },
* mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable.
* scoped_clients?: array<string, string|array{ // Default: []
* scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.
* base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.
* auth_basic?: scalar|Param|null, // An HTTP Basic authentication "username:password".
* auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization.
* auth_ntlm?: scalar|Param|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension).
* query?: array<string, scalar|Param|null>,
* headers?: array<string, mixed>,
* max_redirects?: int|Param, // The maximum number of redirects to follow.
* http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
* resolve?: array<string, scalar|Param|null>,
* proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.
* no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
* bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
* cafile?: scalar|Param|null, // A certificate authority file.
* capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.
* local_cert?: scalar|Param|null, // A PEM formatted certificate file.
* local_pk?: scalar|Param|null, // A private key file.
* passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file.
* ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...).
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
* sha1?: mixed,
* pin-sha256?: mixed,
* md5?: mixed,
* },
* crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
* extra?: array<string, mixed>,
* rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null
* caching?: bool|array{ // Caching configuration.
* enabled?: bool|Param, // Default: false
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
* },
* retry_failed?: bool|array{
* enabled?: bool|Param, // Default: false
* retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null
* http_codes?: array<string, array{ // Default: []
* code?: int|Param,
* methods?: list<string|Param>,
* }>,
* max_retries?: int|Param, // Default: 3
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
* },
* }>,
* },
* mailer?: bool|array{ // Mailer configuration
* enabled?: bool|Param, // Default: false
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
* dsn?: scalar|Param|null, // Default: null
* transports?: array<string, scalar|Param|null>,
* envelope?: array{ // Mailer Envelope configuration
* sender?: scalar|Param|null,
* recipients?: list<scalar|Param|null>,
* allowed_recipients?: list<scalar|Param|null>,
* },
* headers?: array<string, string|array{ // Default: []
* value?: mixed,
* }>,
* dkim_signer?: bool|array{ // DKIM signer configuration
* enabled?: bool|Param, // Default: false
* key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: ""
* domain?: scalar|Param|null, // Default: ""
* select?: scalar|Param|null, // Default: ""
* passphrase?: scalar|Param|null, // The private key passphrase // Default: ""
* options?: array<string, mixed>,
* },
* smime_signer?: bool|array{ // S/MIME signer configuration
* enabled?: bool|Param, // Default: false
* key?: scalar|Param|null, // Path to key (in PEM format) // Default: ""
* certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: ""
* passphrase?: scalar|Param|null, // The private key passphrase // Default: null
* extra_certificates?: scalar|Param|null, // Default: null
* sign_options?: int|Param, // Default: null
* },
* smime_encrypter?: bool|array{ // S/MIME encrypter configuration
* enabled?: bool|Param, // Default: false
* repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: ""
* cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null
* },
* },
* secrets?: bool|array{
* enabled?: bool|Param, // Default: true
* vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%"
* local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local"
* decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET"
* },
* notifier?: bool|array{ // Notifier configuration
* enabled?: bool|Param, // Default: false
* message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
* chatter_transports?: array<string, scalar|Param|null>,
* texter_transports?: array<string, scalar|Param|null>,
* notification_on_failed_messages?: bool|Param, // Default: false
* channel_policy?: array<string, string|list<scalar|Param|null>>,
* admin_recipients?: list<array{ // Default: []
* email?: scalar|Param|null,
* phone?: scalar|Param|null, // Default: ""
* }>,
* },
* rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
* limiters?: list<scalar|Param|null>,
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
* rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket".
* interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
* amount?: int|Param, // Amount of tokens to add each interval. // Default: 1
* },
* }>,
* },
* uid?: bool|array{ // Uid configuration
* enabled?: bool|Param, // Default: true
* default_uuid_version?: 7|6|4|1|Param, // Default: 7
* name_based_uuid_version?: 5|3|Param, // Default: 5
* name_based_uuid_namespace?: scalar|Param|null,
* time_based_uuid_version?: 7|6|1|Param, // Default: 7
* time_based_uuid_node?: scalar|Param|null,
* },
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
* enabled?: bool|Param, // Default: false
* sanitizers?: array<string, array{ // Default: []
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
* allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
* allow_elements?: array<string, mixed>,
* block_elements?: list<string|Param>,
* drop_elements?: list<string|Param>,
* allow_attributes?: array<string, mixed>,
* drop_attributes?: array<string, mixed>,
* force_attributes?: array<string, array<string, string|Param>>,
* force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false
* allowed_link_schemes?: list<string|Param>,
* allowed_link_hosts?: list<string|Param>|null,
* allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false
* allowed_media_schemes?: list<string|Param>,
* allowed_media_hosts?: list<string|Param>|null,
* allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false
* with_attribute_sanitizers?: list<string|Param>,
* without_attribute_sanitizers?: list<string|Param>,
* max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0
* }>,
* },
* webhook?: bool|array{ // Webhook configuration
* enabled?: bool|Param, // Default: false
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
* routing?: array<string, array{ // Default: []
* service?: scalar|Param|null,
* secret?: scalar|Param|null, // Default: ""
* }>,
* },
* remote-event?: bool|array{ // RemoteEvent configuration
* enabled?: bool|Param, // Default: false
* },
* json_streamer?: bool|array{ // JSON streamer configuration
* enabled?: bool|Param, // Default: false
* },
* }
* @psalm-type TwigConfig = array{
* form_themes?: list<scalar|Param|null>,
* globals?: array<string, array{ // Default: []
* id?: scalar|Param|null,
* type?: scalar|Param|null,
* value?: mixed,
* }>,
* autoescape_service?: scalar|Param|null, // Default: null
* autoescape_service_method?: scalar|Param|null, // Default: null
* base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated.
* cache?: scalar|Param|null, // Default: true
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
* debug?: bool|Param, // Default: "%kernel.debug%"
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
* auto_reload?: scalar|Param|null,
* optimizations?: int|Param,
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
* file_name_pattern?: list<scalar|Param|null>,
* paths?: array<string, mixed>,
* date?: array{ // The default format options used by the date filter.
* format?: scalar|Param|null, // Default: "F j, Y H:i"
* interval_format?: scalar|Param|null, // Default: "%d days"
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
* },
* number_format?: array{ // The default format options for the number_format filter.
* decimals?: int|Param, // Default: 0
* decimal_point?: scalar|Param|null, // Default: "."
* thousands_separator?: scalar|Param|null, // Default: ","
* },
* mailer?: array{
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
* },
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* twig?: TwigConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* twig?: TwigConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* twig?: TwigConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* ...<string, ExtensionType>,
* }>
* }
*/
final class App
{
/**
* @param ConfigType $config
*
* @psalm-return ConfigType
*/
public static function config(array $config): array
{
/** @var ConfigType $config */
$config = AppReference::config($config);
return $config;
}
}
namespace Symfony\Component\Routing\Loader\Configurator;
/**
* This class provides array-shapes for configuring the routes of an application.
*
* Example:
*
* ```php
* // config/routes.php
* namespace Symfony\Component\Routing\Loader\Configurator;
*
* return Routes::config([
* 'controllers' => [
* 'resource' => 'routing.controllers',
* ],
* ]);
* ```
*
* @psalm-type RouteConfig = array{
* path: string|array<string,string>,
* controller?: string,
* methods?: string|list<string>,
* requirements?: array<string,string>,
* defaults?: array<string,mixed>,
* options?: array<string,mixed>,
* host?: string|array<string,string>,
* schemes?: string|list<string>,
* condition?: string,
* locale?: string,
* format?: string,
* utf8?: bool,
* stateless?: bool,
* }
* @psalm-type ImportConfig = array{
* resource: string,
* type?: string,
* exclude?: string|list<string>,
* prefix?: string|array<string,string>,
* name_prefix?: string,
* trailing_slash_on_root?: bool,
* controller?: string,
* methods?: string|list<string>,
* requirements?: array<string,string>,
* defaults?: array<string,mixed>,
* options?: array<string,mixed>,
* host?: string|array<string,string>,
* schemes?: string|list<string>,
* condition?: string,
* locale?: string,
* format?: string,
* utf8?: bool,
* stateless?: bool,
* }
* @psalm-type AliasConfig = array{
* alias: string,
* deprecated?: array{package:string, version:string, message?:string},
* }
* @psalm-type RoutesConfig = array{
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* ...<string, RouteConfig|ImportConfig|AliasConfig>
* }
*/
final class Routes
{
/**
* @param RoutesConfig $config
*
* @psalm-return RoutesConfig
*/
public static function config(array $config): array
{
return $config;
}
}
+34 -4
View File
@@ -27,6 +27,16 @@ parameters:
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
# --- remote-user header ---
# Controls the value sent in the Remote-User header on successful auth.
# session: the session id (default, backward-compatible)
# static: a fixed string (set via REMOTE_USER_STATIC)
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
# none: do not send the Remote-User header at all
env(REMOTE_USER): 'session'
env(REMOTE_USER_STATIC): 'authenticated'
env(REMOTE_USER_MAP): ''
# --- rate limiting ---
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
@@ -35,6 +45,16 @@ parameters:
env(UPPER_COUNT): 10 # 10 per hour
env(UPPER_TIME): 3600 # seconds (1 hour)
# --- public access (rate-limited, no auth required) ---
# Comma-separated path patterns for public access. Wildcards: * (single
# segment), ** (cross segments). Optional host prefix: host.com/path/**
# When empty (default), the feature is fully disabled.
env(PUBLIC_PATHS): ''
env(PUBLIC_BURST_COUNT): 100 # max requests per burst window per IP
env(PUBLIC_BURST_TIME): 60 # burst window in seconds
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
# --- styling options ---
env(TITLE): 'Pre-Authentication System'
env(BG_COLOR): '#029386' # teal
@@ -56,12 +76,22 @@ parameters:
# --- application variables ---
app.totp_uri: '%env(TOTP_URI)%'
app.cookie_ttl: '%env(COOKIE_TTL)%'
app.subdomain_redirect: '%env(SUBDOMAIN_REDIRECT)%'
app.cookie_ttl: '%env(int:COOKIE_TTL)%'
app.subdomain_redirect: '%env(bool:SUBDOMAIN_REDIRECT)%'
app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%'
app.ip_ttl: '%env(IP_TTL)%'
app.teapot: '%env(TEAPOT)%'
app.ip_ttl: '%env(int:IP_TTL)%'
app.teapot: '%env(bool:TEAPOT)%'
app.remote_user: '%env(REMOTE_USER)%'
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
app.public_paths: '%env(PUBLIC_PATHS)%'
app.public_burst_count: '%env(int:PUBLIC_BURST_COUNT)%'
app.public_burst_time: '%env(int:PUBLIC_BURST_TIME)%'
app.public_upper_count: '%env(int:PUBLIC_UPPER_COUNT)%'
app.public_upper_time: '%env(int:PUBLIC_UPPER_TIME)%'
app.error_message: '%env(ERROR_MESSAGE)%'
app.teapot_title: '%env(TEAPOT_TITLE)%'
+23 -1
View File
@@ -20,9 +20,31 @@ protected.example.com {
reverse_proxy protected-service:9000
}
# optionally, if you want to use a subdomain for centeral preauth
# optionally, if you want to use a subdomain for central preauth
# set SUBDOMAIN_REDIRECT to true
# and AUTH_SUBDOMAIN to match the subdomain you use here
auth.example.com {
reverse_proxy preauth
}
# --- public rate-limited access (v1.1) ---
# Configure PUBLIC_PATHS env var to specify which paths are public.
# Example: PUBLIC_PATHS=/public/**
# Unauthenticated visitors to public paths are rate-limited separately
# from login attempts. Authenticated users bypass the public rate limiter.
#
# This example protects all of Gitea except /public/** which is
# publicly accessible but rate-limited (e.g., 100 req/min, 500 req/hr).
git.example.com {
forward_auth preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy gitea:3000
}
# In preauth's .env:
# PUBLIC_PATHS=/public/**
# PUBLIC_BURST_COUNT=100
# PUBLIC_BURST_TIME=60
# PUBLIC_UPPER_COUNT=500
# PUBLIC_UPPER_TIME=3600
+1 -1
View File
@@ -1,7 +1,7 @@
services:
preauth:
env_file:
# TODO rename "example.env" to ".env", edit as needed
# rename "example.env" to ".env", edit as needed
# strongly recommend setting TOTP_URI, if not provided the app
# will generate one for you, please copy it into your .env file
- .env
+22
View File
@@ -24,6 +24,16 @@
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
# --- remote-user header ---
# Controls the value sent in the Remote-User header on successful auth.
# session: the session id (default, backward-compatible)
# static: a fixed string (set via REMOTE_USER_STATIC)
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
# none: do not send the Remote-User header at all
#REMOTE_USER=session
#REMOTE_USER_STATIC=authenticated
#REMOTE_USER_MAP=''
# --- rate limiting ---
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
@@ -33,6 +43,18 @@
#UPPER_COUNT=10 # 10 per hour
#UPPER_TIME=3600 # seconds (1 hour)
# --- public access (rate-limited, no auth required) ---
# Comma-separated path patterns for public access. Wildcards:
# * matches any chars within one path segment (not crossing /)
# ** matches any chars including / (crosses path segments)
# Optional host prefix: host.example.com/path/**
# When empty (default), the feature is fully disabled.
#PUBLIC_PATHS=''
#PUBLIC_BURST_COUNT=100 # max requests per burst window per IP
#PUBLIC_BURST_TIME=60 # burst window in seconds
#PUBLIC_UPPER_COUNT=500 # max requests per sustained window per IP
#PUBLIC_UPPER_TIME=3600 # sustained window in seconds (1 hour)
# --- styling options ---
#TITLE='Pre-Authentication System'
+340
View File
@@ -0,0 +1,340 @@
# v1.1 Plan — Public Rate-Limited Access
## Goal
Allow preauth to provide rate-limited unauthenticated access to select
public paths. Authenticated users bypass the public rate limiter entirely.
Non-public paths continue to trigger the existing auth flow.
**Practical example:** Allow anyone to visit
`https://code.devgnome.com/public/*` in Gitea, but limit them to 100
requests/minute and 500 requests/hour per IP.
---
## How It Works
When a request arrives and the user is **not authenticated** (no valid
cookie or IP session), the new `PublicAccessListener` checks whether the
request path matches any configured public path pattern. If it does:
1. The public rate limiter is consulted (separate from the login limiter).
2. If within limits → `200 OK` (no `Remote-User` header). Caddy proxies
to the backend.
3. If over limits → `429 Too Many Requests` with a `Retry-After` header.
If the path does **not** match any public pattern, the request falls
through to the existing auth flow (RejectListener → LoginListener →
InterceptListener → login page or redirect).
**Authenticated users** never reach the `PublicAccessListener` because
`AcceptListener` (priority 99) or `AllowListener` (priority 88) will have
already set a `200` response before `PublicAccessListener` runs.
### Listener Priority Chain (updated)
```
Priority Listener Action
──────── ───────────────── ──────────────────────────────────────
99 AcceptListener Valid cookie → 200 OK
88 AllowListener Valid IP session → 200 OK
84 PublicAccessListener Public path + rate limit check → 200 or 429
77 RejectListener Login rate-limit gate → 418/429
66 LoginListener Login attempt handling
55 InterceptListener Fallback → redirect or login page
```
`PublicAccessListener` runs at priority 84 — after auth checks (so
authenticated users bypass it) but before `RejectListener` (so public
access is not subject to the login rate limiter).
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. Wildcard `*` supported. |
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP |
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds |
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP |
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour) |
**When `PUBLIC_PATHS` is empty (default), the feature is completely
disabled and has zero effect on existing behavior.**
### Path Pattern Syntax
- Patterns are matched against the request **path** only (query string
is ignored).
- Patterns must start with `/`.
- `*` matches any sequence of characters within a single path segment
(not crossing `/`).
- `**` matches any sequence of characters including `/` (crosses path
segments).
- No other regex or special characters are supported — patterns are
literal strings with `*` wildcards.
**Examples:**
| Pattern | Matches | Does NOT match |
|---------|---------|----------------|
| `/public` | `/public` | `/public/`, `/public/xyz` |
| `/public/*` | `/public/anything`, `/public/xyz` | `/public`, `/public/a/b` |
| `/public/**` | `/public/anything`, `/public/a/b/c` | `/public` |
| `/public` | `/public` | `/public/xyz` |
| `/api/*/status` | `/api/v1/status`, `/api/v2/status` | `/api/v1/v2/status` |
### Domain-Scoped Paths (when using auth subdomain)
When `SUBDOMAIN_REDIRECT=true` and `AUTH_SUBDOMAIN` is set, the user may
want public paths on specific subdomains only. In this case, `PUBLIC_PATHS`
can optionally include a domain prefix:
```
PUBLIC_PATHS='code.devgnome.com/public/**,auth.devgnome.com/health'
```
When no domain prefix is given, the path matches on **any** host. When a
domain prefix is given, it only matches on that specific host.
When **not** using an auth subdomain (the common case), paths without a
domain prefix match on all hosts. Domain-prefixed entries can still be
used to restrict to specific hosts.
### Rate Limiter
A new `public_limiter` compound rate limiter is added to
`rate_limiter.yaml`, following the same pattern as the existing
`login_limiter`. It uses a `publicRateLimitCache` pool (APCu in
production, array adapter in tests).
---
## New Files
| File | Purpose |
|------|---------|
| `src/Service/PublicPathMatcher.php` | Service that parses `PUBLIC_PATHS` and matches request paths against patterns |
| `src/Service/PublicPathMatcherInterface.php` | Interface for testability |
| `src/Listener/PublicAccessListener.php` | Listener that checks public paths and applies rate limiting |
## Modified Files
| File | Changes |
|------|---------|
| `config/services.yaml` | Add `PUBLIC_PATHS` and related env vars + parameters |
| `config/packages/rate_limiter.yaml` | Add `public_burst`, `public_upper`, `public_limiter` |
| `config/packages/cache.yaml` | Add `publicRateLimitCache` pool |
| `config/packages/test/cache.yaml` | Add `publicRateLimitCache` pool (array adapter) |
| `src/ConfigBag.php` | Add `publicPaths()` method returning parsed path patterns |
| `tests/TestKernel.php` | Add `publicRateLimitCache` to reset exclusion list |
| `tests/Support/ListenerTestHelper.php` | Add helper for public rate limiter factory |
| `docs/example.env` | Document new env vars |
| `.env.test` | Add test defaults for public paths vars |
| `.env` | Add dev defaults for public paths vars |
| `docs/Caddyfile` | Add example of public + protected service config |
| `CHANGELOG.md` | Add v1.1 section |
| `ROADMAP.md` | Mark Phase 1 as in-progress / completed |
| `readme.md` | Document public access feature |
## New Test Files
| File | Coverage |
|------|----------|
| `tests/Unit/Service/PublicPathMatcherTest.php` | Pattern parsing, matching, wildcards, domain scoping |
| `tests/Unit/Listener/PublicAccessListenerTest.php` | Listener logic: public path match → 200, non-public → pass through, rate limited → 429, authenticated → not reached |
| `tests/Functional/PublicAccessFlowTest.php` | End-to-end: public path accessible, rate limit enforced, non-public path shows login, authenticated user bypasses public rate limit |
---
## Implementation Order
1. **`PublicPathMatcher`** — Pure path matching logic, no dependencies.
Parse the `PUBLIC_PATHS` string into pattern entries (each with
optional host + path pattern). Convert `*`/`**` wildcards to regex.
Match a given (host, path) against all patterns.
2. **Config** — Add env vars to `services.yaml`, add rate limiter to
`rate_limiter.yaml`, add cache pool to `cache.yaml` + test cache.
3. **`ConfigBag`** — Add `publicPaths()` returning the raw string (the
`PublicPathMatcher` does the parsing). Or add the `PublicPathMatcher`
as a service that receives the raw string via autowiring.
4. **`PublicAccessListener`** — Inject `PublicPathMatcherInterface`,
`RateLimiterFactoryInterface` (target `public_limiter`), and
`ConfigBag`. On `RequestEvent`:
- If no public paths configured → return immediately.
- If request already has a response → return (auth listeners ran first).
- Check if (host, path) matches any public pattern.
- If no match → return (fall through to auth flow).
- If match → consume(1) from public rate limiter.
- If over limit → set 429 response with `Retry-After`.
- If within limit → set 200 response (plain text, no `Remote-User`).
5. **Tests** — Unit tests for `PublicPathMatcher` and
`PublicAccessListener`, functional tests for the full flow.
6. **Documentation** — Update all docs.
7. **Lint + Test** — Run php-cs-fixer + phpunit, fix any issues.
8. **Commit + Push + PR.**
---
## Key Design Decisions
### Why priority 84?
- Must be **after** `AcceptListener` (99) and `AllowListener` (88) so
authenticated users never hit the public rate limiter.
- Must be **before** `RejectListener` (77) so public access is not
blocked by the login attempt rate limiter.
- Must be **before** `LoginListener` (66) so login attempts on public
paths are still processed (though this is an edge case — a login
attempt on a public path would set a response in `PublicAccessListener`
before `LoginListener` runs, which is correct: you don't need to login
to access a public path).
**Wait — actually this is a problem.** If someone sends an `X-Preauth`
header on a public path, `PublicAccessListener` would return 200 before
`LoginListener` can process the login. But that's actually fine — if the
path is public, they don't need to log in. If they want to authenticate,
they can visit a non-public path.
**Revised approach:** `PublicAccessListener` should only return 200 for
**GET/HEAD** requests to public paths, or all methods? For a gate like
this, all methods should be allowed on public paths — the backend
service (e.g., Gitea) handles its own authorization for write
operations.
### Why a separate rate limiter?
The existing `login_limiter` rate limits **login attempts** (failures).
The public rate limiter rate limits **all requests** to public paths.
They serve different purposes and need independent counters. Using the
same limiter would mean public traffic could exhaust the login attempt
budget, or vice versa.
### Why `Retry-After` header?
It's a standard HTTP header (RFC 7231) that tells clients how long to
wait before retrying. Legitimate clients (browsers, API consumers) and
crawlers respect it.
### Why no `Remote-User` header on public responses?
The `Remote-User` header tells the backend who the authenticated user
is. For public access, there is no authenticated user. Sending
`Remote-User: public` or similar could confuse the backend. The backend
should treat requests without `Remote-User` as anonymous.
### Path matching: query strings
Query strings are **ignored** for path matching. `/public?foo=bar`
matches the pattern `/public`. This is implemented by using
`$request->getPathInfo()` which returns the path without query string.
---
## Edge Cases
1. **Empty `PUBLIC_PATHS`** → Feature disabled, zero impact on existing
behavior. All tests pass unchanged.
2. **Authenticated user visits a public path**`AcceptListener` or
`AllowListener` returns 200 before `PublicAccessListener` runs. The
public rate limiter is never consulted.
3. **Public path rate limit exceeded** → 429 with `Retry-After` header.
The response uses the error template (same as login rate limit) but
always with 429 status (never teapot — teapot is for login failures).
4. **Non-public path on a host that has some public paths** → Falls
through to the normal auth flow. Login page or redirect.
5. **`PUBLIC_PATHS` with whitespace** → Trimmed during parsing.
`PUBLIC_PATHS='/public, /api'` is equivalent to `/public,/api`.
6. **Invalid patterns** (not starting with `/`) → Silently ignored
during parsing. Logged at debug level.
7. **Login attempt on a public path**`PublicAccessListener` returns
200 before `LoginListener` runs. This is correct behavior — if the
path is public, no login is needed.
8. **Subdomain redirect mode + public paths** → If using an auth
subdomain, requests to the auth subdomain itself should never be
treated as public. The `PublicAccessListener` should skip requests
where `host === authSubdomain`.
---
## Test Strategy
### Unit Tests — `PublicPathMatcherTest`
- Empty string → no patterns → matches nothing
- Single path `/public` → matches exact, not `/public/`
- Wildcard `/public/*` → matches `/public/x`, not `/public`, not `/public/a/b`
- Double wildcard `/public/**` → matches `/public/a/b/c`
- Multiple patterns comma-separated
- Domain-prefixed pattern `host.example.com/public/**`
- Path without domain prefix matches any host
- Whitespace trimming
- Invalid patterns (no leading `/`) ignored
- Case sensitivity (paths are case-sensitive, hosts are case-insensitive)
### Unit Tests — `PublicAccessListenerTest`
- No public paths configured → returns without setting response
- Non-public path → returns without setting response
- Public path, within rate limit → sets 200 response
- Public path, rate limit exceeded → sets 429 response with Retry-After
- Public path, response already set by earlier listener → returns
- Auth subdomain request → skipped (even if path matches)
- Uses `ListenerTestHelper` for mock rate limiters and collaborators
### Functional Tests — `PublicAccessFlowTest`
- Public path accessible without authentication → 200
- Non-public path without auth → 401 (login page)
- Rate limit enforcement: multiple requests exceed burst → 429
- Authenticated user visits public path → 200 with Remote-User (bypasses public limiter)
- 429 response includes Retry-After header
- Query string ignored for path matching
- Wildcard matching works end-to-end
---
## Documentation Updates
### README
New section: **"Public Rate-Limited Access"** under Configuration.
- Explain the feature and use case
- Document all env vars
- Show path pattern syntax with examples
- Show Caddyfile configuration for public + protected services
- Note that authenticated users bypass the public rate limiter
### CHANGELOG
New `[Unreleased]` → v1.1 section with all new features.
### ROADMAP
Mark Phase 1 items as completed.
### docs/example.env
Add all new env vars with comments.
### docs/Caddyfile
Add example showing a service with both public and protected paths.
+6
View File
@@ -22,6 +22,12 @@
<!-- high rate limits so functional tests don't get blocked -->
<server name="BURST_COUNT" value="10000" />
<server name="UPPER_COUNT" value="10000" />
<!-- public access: enable for functional tests with low limits -->
<server name="PUBLIC_PATHS" value="/public/**" />
<server name="PUBLIC_BURST_COUNT" value="3" />
<server name="PUBLIC_BURST_TIME" value="60" />
<server name="PUBLIC_UPPER_COUNT" value="10000" />
<server name="PUBLIC_UPPER_TIME" value="3600" />
</php>
<testsuites>
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
use App\Kernel;
+263 -42
View File
@@ -1,68 +1,289 @@
# Preauth
For when you want to expose a web service without letting the whole world try to access it. Because sometimes you want both a belt and suspenders.
I found myself needing to make my personal Nextcloud instance available outside my VPN, but was worried since it has had authentication exploits in the past.
A lightweight TOTP authentication gateway for self-hosted web services.
So, I built a simple authentication gateway, which eventually turned into this project.
Preauth sits between your reverse proxy (Caddy) and your web service,
requiring a TOTP code before traffic ever reaches the protected application.
It is **not** a replacement for your service's own authentication — it's a
gate that prevents outsiders from even seeing what service is running.
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
For when you want a belt and suspenders.
## Requirements
## Features
* Docker
* Caddy (as a reverse proxy)
* a web service you want to secure
- **TOTP authentication** — Time-based one-time passwords (compatible with
Google Authenticator, Authy, 1Password, etc.)
- **Backup codes** — Single-use backup codes for when TOTP devices are lost
- **Caddy native** — Designed for Caddy's `forward_auth` directive
- **Docker-first** — Single container, persistent volumes, no database
- **Rate limiting** — Per-IP burst and sustained limits (cannot be disabled)
- **Public rate-limited access** — Optional, allow unauthenticated access
to specific paths with separate rate limiting (e.g., public Gitea repos)
- **Central auth** — Optional subdomain-based SSO across multiple services
- **IP-based bypass** — Optional, for services that don't handle cookies
- **Customizable** — Colors, labels, messages, and error text via env vars
- **Teapot mode** — Respond with `418 I'm a Teapot` when rate-limited
(because it's more fun than `429 Too Many Requests`)
- **Cookie security** — `__Host-` prefixed cookies with `SameSite=Strict`,
`Secure`, and `HttpOnly`
- **Nonce system** — Single-use nonces prevent replay and CSRF attacks
- **Dual-layer cache** — APCu for speed, file-based persistence for restarts
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
## Quick Start
There is an example Caddyfile in /docs/ and env.example file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
### 1. Pull the Docker image
When someone tries to reach your protected web service, Caddy will check with preauth if they are allowed, if their preauth cookie is missing, invalid, or expired, we will show them to a login screen.
```bash
docker pull digitaladapt/preauth:latest
```
I say login, but it's really just a TOTP code (6-digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
### 2. Create your environment file
First time you spin up the docker container it will generate a TOTP secret (which you'll load into your authenticator app); or generate you own.
```bash
# Generate a TOTP secret to get started
openssl rand -base64 30
```
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
Create a `.env` file (see `docs/example.env` for all options):
## Backup Codes
```env
APP_SECRET=your-random-secret-here
TOTP_URI=otpauth://totp/Preauth?secret=YOUR_SECRET
COOKIE_TTL=2592000
```
It is possible to generate single-use backup codes via a console command within the docker container.
> If `TOTP_URI` is left blank, the app will generate one on first run
> and print it to the container logs. Copy it to your `.env` file.
```shell
### 3. Start the container
```bash
docker compose up -d
```
See `docs/compose.yaml` for an example Docker Compose file.
### 4. Configure Caddy
```caddyfile
service.example.com {
forward_auth preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy your-service:80
}
```
See `docs/Caddyfile` for more examples, including path-specific protection
and central auth subdomain configuration.
### 5. Generate backup codes (optional)
```bash
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
```
### History
#### v0.7.0 (May 29th, 2026)
Added ability to generate single-use backup codes.
Removed static password and lookup token, as they were security risks.
Updated to PHP 8.5, updated dependencies.
## Requirements
#### v0.6.0 (Feb 10th, 2026)
Added optional (disabled by default) ability to lookup token by static password.
- **Docker** — Preauth runs as a Docker container
- **Caddy** — As your reverse proxy (uses `forward_auth` directive)
- **A web service** — The application you want to protect
#### v0.5.0 (Jan 17th, 2026)
Nonce related cleanup; added optional (disabled by default) ability to use a static password as a backup means of authentication.
Other reverse proxies with similar `forward_auth` / `auth_request`
capabilities may work, but only Caddy is officially supported.
#### v0.4.1 (Dec 26th, 2025)
Fixed bug which can occur if you delete cache files.
## Configuration
#### v0.4.0 (Dec 26th, 2025)
Massive rewrite to switch to using listeners instead of controller, header for login payload instead of get request, removed icon system, asset system, was able to remove all the domain processing, enhanced cookie security, and more.
All configuration is via environment variables. See `docs/example.env`
for the complete reference.
#### v0.3.0 (Dec 15th, 2025)
Includes significant breaking changes.
Default port and transportation changed to http via port 80.
Names of environment variables have changed.
### Main Options
#### v0.2.0 (Dec 3rd, 2025)
Now with login rate limiting.
New page for client error (too many requests).
Made example docker compose.
| Variable | Default | Description |
|----------|---------|-------------|
| `TOTP_URI` | _(empty)_ | TOTP provisioning URI. If blank, one is generated on first run. |
| `COOKIE_TTL` | `2592000` | Session duration in seconds (default: 30 days). |
| `SUBDOMAIN_REDIRECT` | `0` | Enable central auth across subdomains (boolean). |
| `AUTH_SUBDOMAIN` | _(empty)_ | Hostname for central auth (e.g., `auth.example.com`). |
#### v0.1.0 (Nov 14th, 2025)
Now an actual project, docker image pushed to docker hub, which uses php-fpm, code into a src folder, templates into separate files.
### Extra Options
#### v0.0.1 (June 26th, 2024)
Started off as a single file script which was part of my caddy config. Hardcoded TOTP secret, zero flexibility, but functional. Would stay like that, quietly working in production for about a full year before any real change.
| Variable | Default | Description |
|----------|---------|-------------|
| `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). |
### Remote-User Header
The `Remote-User` header sent to backends on successful auth is configurable:
| Variable | Default | Description |
|----------|---------|-------------|
| `REMOTE_USER` | `session` | Mode: `session`, `static`, `mapped`, or `none`. |
| `REMOTE_USER_STATIC` | `authenticated` | Value sent when mode is `static`. |
| `REMOTE_USER_MAP` | _(empty)_ | Comma-separated map for `mapped` mode (e.g. `alice:admin,bob:user`). |
- **`session`** (default): Sends the session id. Backward-compatible.
- **`static`**: Sends a fixed string for all authenticated requests.
- **`mapped`**: Looks up the session id in the map; falls back to session id if not found.
- **`none`**: Omits the header entirely (Caddy still accepts based on status code).
### Rate Limiting
Rate limiting **cannot be disabled**. It uses a compound sliding window:
| Variable | Default | Description |
|----------|---------|-------------|
| `BURST_COUNT` | `2` | Max attempts per burst window. |
| `BURST_TIME` | `30` | Burst window in seconds. |
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
### Public Rate-Limited Access
Preauth can provide rate-limited unauthenticated access to select public
paths. This is useful for exposing public content (e.g., public repositories
in Gitea) without requiring TOTP authentication, while protecting server
resources from bot traffic.
When `PUBLIC_PATHS` is configured, requests to matching paths from
unauthenticated users are allowed through with a separate rate limiter.
Authenticated users bypass the public rate limiter entirely.
| Variable | Default | Description |
|----------|---------|-------------|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. See below. |
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP. |
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds. |
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP. |
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour). |
**Path pattern syntax:**
- Patterns are matched against the request path only (query string ignored).
- Patterns must start with `/`.
- `*` matches one or more characters within a single path segment (not crossing `/`).
- `**` matches zero or more characters including `/` (crosses path segments).
- An optional host prefix can restrict a pattern to a specific host
(e.g., `code.example.com/public/**`).
| Pattern | Matches | Does NOT match |
|---------|---------|----------------|
| `/public` | `/public` | `/public/`, `/public/repo` |
| `/public/*` | `/public/repo` | `/public`, `/public/a/b` |
| `/public/**` | `/public/repo`, `/public/a/b/c` | `/public` |
| `host.com/api/**` | `host.com/api/v1/status` | `other.com/api/v1/status` |
**Example:** Allow public access to Gitea's `/public/` paths:
```env
PUBLIC_PATHS=/public/**
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
PUBLIC_UPPER_COUNT=500
PUBLIC_UPPER_TIME=3600
```
When a visitor exceeds the rate limit, they receive a `429 Too Many Requests`
response with a `Retry-After` header. When within limits, they receive a
`200 OK` response (with no `Remote-User` header). Authenticated users receive
`200 OK` with their `Remote-User` header as normal.
### Styling
All UI text and colors are configurable:
| Variable | Default | Description |
|----------|---------|-------------|
| `TITLE` | `Pre-Authentication System` | Page title. |
| `BG_COLOR` | `#029386` | Background color. |
| `FG_COLOR` | `#ffffff` | Foreground (text) color. |
| `ERROR_COLOR` | `#ffb16d` | Error message color. |
| `ID_NAME` | `Session ID` | Label for the ID field. |
| `TOKEN_NAME` | `Authentication Token` | Label for the TOTP field. |
| `SUBMIT_NAME` | `Submit` | Submit button text. |
| `ERROR_MESSAGE` | `Unsuccessful login attempt` | Failed login message. |
| `TEAPOT_TITLE` | `I'm a teapot` | Title when rate-limited (teapot mode). |
| `TEAPOT_MESSAGE` | `I refuse to brew coffee` | Message when rate-limited (teapot mode). |
| `TOO_MANY_TITLE` | `Too many requests` | Title when rate-limited (non-teapot). |
| `TOO_MANY_MESSAGE` | `Try again later` | Message when rate-limited (non-teapot). |
## Architecture
```
Client → Caddy → forward_auth → Preauth listeners → 200/401/418
```
Preauth is entirely event-listener-driven (no controllers). Each request
passes through a priority-ordered chain of listeners:
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
3. **PublicAccessListener** (priority 84) — If public paths are configured,
allows rate-limited unauthenticated access to matching paths.
4. **RejectListener** (priority 77) — Rate-limiting gate.
5. **LoginListener** (priority 66) — Processes login attempts.
6. **InterceptListener** (priority 55) — Renders login page or redirects.
7. **SecurityHeadersListener** (response) — Adds security headers.
### Security Model
- **Cookies**: `__Host-` prefixed, `SameSite=Strict`, `Secure`, `HttpOnly`
- **Nonces**: 15-byte random, single-use, 120-second TTL
- **TOTP**: ±1 period leeway (±30 seconds) for clock drift
- **Backup codes**: Case-insensitive, single-use, alphanumeric
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, HSTS
### Cache
Preauth uses a dual-layer cache:
- **APCu** (in-memory) — Fast session and nonce lookups
- **Filesystem** — Persistent storage for container restarts
`MonitorCacheKeys` wraps the PSR-6 cache pool to track changes, so only
modified items are persisted to disk on shutdown.
## Development
### Code Style
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and
includes `php-cs-fixer` as a dev dependency.
```bash
# Check for style violations
vendor/bin/php-cs-fixer fix --dry-run --diff
# Auto-fix
vendor/bin/php-cs-fixer fix
```
### Running Tests
```bash
vendor/bin/phpunit
```
The test suite includes 293 tests with 100% code coverage (lines, methods,
and classes). Both unit tests and functional tests (full HTTP kernel flow)
are included.
### Requirements
- PHP 8.4+
- Composer
- Xdebug (for coverage reports)
## License
MIT — see `license.txt`.
## Project Status
Running in production since June 2024, protecting multiple self-hosted
services. The core authentication gate is complete and battle-tested.
See `ROADMAP.md` for planned features and `CHANGELOG.md` for version history.
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App;
/**
* Shared application constants.
*/
final class AppConstants
{
/**
* Far-future expiration date used for persistent cache items
* (TOTP secrets, backup codes) that should effectively never expire.
* Per PSR-6, if no expiration is set, the implementation may set a
* default — we use this to be explicit.
*/
public const string FAR_FUTURE_DATE = '2999-12-31';
/**
* Maximum length for user-supplied input fields (id, nonce, token).
* Also used for cache key truncation.
*/
public const int MAX_INPUT_LENGTH = 128;
}
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -8,8 +9,10 @@ use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
#[AsAlias(ClockInterface::class)]
final readonly class Clock implements ClockInterface {
public function now(): DateTimeImmutable {
final readonly class Clock implements ClockInterface
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
}
+14 -5
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Command;
@@ -6,14 +7,18 @@ namespace App\Command;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
/** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */
final class GenerateBackupCodesCommand extends Command {
#[AsCommand(name: 'app:generate-backup-codes')]
final class GenerateBackupCodesCommand extends Command
{
public function __construct(
private readonly BackupCodeInterface $manager,
private readonly PersistCache $persistCache,
@@ -21,17 +26,21 @@ final class GenerateBackupCodesCommand extends Command {
parent::__construct();
}
protected function configure(): void {
$this->setName('app:generate-backup-codes');
$this->setDescription('Generate singleuse backup codes')
protected function configure(): void
{
$this->setDescription('Generate single-use backup codes')
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
}
/** @throws InvalidArgumentException */
protected function execute(InputInterface $input, OutputInterface $output): int {
protected function execute(InputInterface $input, OutputInterface $output): int
{
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
$this->persistCache->boot();
$count = (int) $input->getArgument('count');
if ($count < 1) {
throw new ConsoleInvalidArgumentException('Count must be a positive integer.');
}
$codes = $this->manager->generate($count);
foreach ($codes as $code) {
$output->writeln($code);
+70 -9
View File
@@ -1,13 +1,16 @@
<?php
declare(strict_types=1);
namespace App;
use App\Enum\RemoteUserMode;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ConfigBag {
final readonly class ConfigBag
{
private ClockInterface $clock;
private int $cookieTtl;
private string $totpUri;
@@ -16,6 +19,10 @@ final readonly class ConfigBag {
private string $errorMessage;
private string $teapotTitle;
private string $tooManyTitle;
private RemoteUserMode $remoteUserMode;
private string $remoteUserStatic;
/** @var array<string,string> */
private array $remoteUserMap;
/** @throws InvalidArgumentException */
public function __construct(
@@ -28,6 +35,9 @@ final readonly class ConfigBag {
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
#[Autowire('%app.remote_user%')] string $remoteUserMode,
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
) {
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
@@ -37,37 +47,88 @@ final readonly class ConfigBag {
$this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle;
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserStatic = $remoteUserStatic;
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
}
public function clock(): ClockInterface {
/**
* Parse a comma-separated map string ("id1:user1,id2:user2") into an array.
*
* @return array<string,string>
*/
private function parseUserMap(string $map): array
{
if ($map === '') {
return [];
}
$result = [];
foreach (explode(',', $map) as $pair) {
$parts = explode(':', trim($pair), 2);
if (count($parts) === 2) {
$result[trim($parts[0])] = trim($parts[1]);
}
}
return $result;
}
public function clock(): ClockInterface
{
return $this->clock;
}
public function cookieTtl(): int {
public function cookieTtl(): int
{
return $this->cookieTtl;
}
public function totpUri(): string {
public function totpUri(): string
{
return $this->totpUri;
}
public function ipTtl(): ?int {
public function ipTtl(): ?int
{
return $this->ipTtl;
}
public function teapot(): bool {
public function teapot(): bool
{
return $this->teapot;
}
public function errorMessage(): string {
public function errorMessage(): string
{
return $this->errorMessage;
}
public function teapotTitle(): string {
public function teapotTitle(): string
{
return $this->teapotTitle;
}
public function tooManyTitle(): string {
public function tooManyTitle(): string
{
return $this->tooManyTitle;
}
public function remoteUserMode(): RemoteUserMode
{
return $this->remoteUserMode;
}
public function remoteUserStatic(): string
{
return $this->remoteUserStatic;
}
/**
* @return array<string,string>
*/
public function remoteUserMap(): array
{
return $this->remoteUserMap;
}
}
+20 -12
View File
@@ -1,24 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Data;
use App\AppConstants;
use App\Enum\Scope;
use Symfony\Component\HttpFoundation\InputBag;
/** when scope is IP but ip-access is disabled, scope is to be considered cookie */
final class Payload {
final class Payload
{
public string $id; /* session name, identifying who is logging in */
public string $token; /* TOTP, typically six digits */
public string $nonce; /* random unique string, to block duplicate submissions */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
public static function decode(string $base64url): ?Payload {
public static function decode(string $base64url): ?Payload
{
/* convert the base64url into json string */
$json = base64_decode(str_pad(strtr($base64url, '-_', '+/'),
strlen($base64url) % 4, '='
), true);
$base64 = strtr($base64url, '-_', '+/');
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
$json = base64_decode($base64, true);
if ($json) {
/* convert the json string into real data */
$data = json_decode($json);
@@ -29,7 +33,8 @@ final class Payload {
return null;
}
public static function load(InputBag $input): ?Payload {
public static function load(InputBag $input): ?Payload
{
/* convert form data into real data */
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[
@@ -42,7 +47,8 @@ final class Payload {
return null;
}
public static function create(object $data): ?Payload {
public static function create(object $data): ?Payload
{
/* if missing required fields id, nonce, or token */
if (strlen(trim($data->id ?? '')) < 1 ||
strlen(trim($data->nonce ?? '')) < 1 ||
@@ -54,20 +60,22 @@ final class Payload {
/* all input is limited */
$payload = new Payload();
$payload->id = mb_substr(trim($data->id), 0, 128);
$payload->nonce = mb_substr(trim($data->nonce), 0, 128);
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->json = ($data->json ?? true);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = mb_substr(trim($data->token), 0, 128);
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
return Payload::constrict($payload);
}
public function toString(): string {
public function toString(): string
{
return json_encode($this);
}
private static function constrict(Payload $payload): Payload {
private static function constrict(Payload $payload): Payload
{
/* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) {
$payload->json = false;
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Controls what value is sent in the Remote-User header on auth success.
*/
enum RemoteUserMode: string
{
/** Send the session id (current/default behaviour). */
case Session = 'session';
/** Send a fixed static string for all authenticated requests. */
case Static = 'static';
/** Look up the session id in a configured map and send the mapped value. */
case Mapped = 'mapped';
/** Do not send the Remote-User header at all. */
case None = 'none';
}
+3 -1
View File
@@ -1,10 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/** scope defines the context of how a session is persisted */
enum Scope: string {
enum Scope: string
{
case Cookie = 'cookie';
case Ip = 'ip';
case None = 'none';
+12 -6
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -9,13 +10,15 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel {
class Kernel extends BaseKernel
{
use MicroKernelTrait;
private PersistCache $persistCache;
/** @throws InvalidArgumentException */
public function boot(): void {
public function boot(): void
{
parent::boot();
$this->persistCache = $this->container->get(PersistCache::class);
@@ -23,9 +26,12 @@ class Kernel extends BaseKernel {
}
/** @throws InvalidArgumentException */
public function terminate(Request $request, Response $response): void {
$this->persistCache->persist();
parent::terminate($request, $response);
public function terminate(Request $request, Response $response): void
{
try {
$this->persistCache->persist();
} finally {
parent::terminate($request, $response);
}
}
}
+34 -17
View File
@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Service\DomainInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
@@ -10,10 +12,10 @@ use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final readonly class AcceptListener {
final readonly class AcceptListener
{
use CookieNameTrait;
use HasLoggerTrait;
use StringTrait;
@@ -21,25 +23,40 @@ final readonly class AcceptListener {
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
) {}
private ConfigBag $config,
) {
}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 99)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
/* check if they sent the correct preauth cookie */
$cookieName = $this->domainManager->authBase() ?$this->authCookieName() : $this->cookieName();
if ($event->getRequest()->cookies->has($cookieName)) {
$cookie = $event->getRequest()->cookies->get($cookieName);
$cookieKey = $this->makeCacheKey("cookie_$cookie");
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */
$id = $this->sessionCache->getItem($cookieKey)->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
$cookieName = $this->sessionCookieName($this->domainManager);
if (! $event->getRequest()->cookies->has($cookieName)) {
return;
}
$cookie = $event->getRequest()->cookies->get($cookieName);
$cookieKey = $this->makeCacheKey("cookie_$cookie");
try {
if (! $cookie || ! $this->sessionCache->hasItem($cookieKey)) {
return;
}
/* cookie sent corresponds to valid existing session */
$item = $this->sessionCache->getItem($cookieKey);
if (! $item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
$id = $item->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse($this->authSuccessResponse($id, $this->config));
} catch (InvalidArgumentException $e) {
/* cache failure — fail closed (don't authenticate) */
$this->logger->error("cache error in AcceptListener: {$e->getMessage()}");
}
}
}
+30 -15
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -9,32 +10,46 @@ use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final readonly class AllowListener {
final readonly class AllowListener
{
use HasLoggerTrait;
use StringTrait;
public function __construct(
private CacheItemPoolInterface $sessionCache,
private ConfigBag $config,
) {}
) {
}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 88)]
public function onKernelRequest(RequestEvent $event): void {
if ($this->config->ipTtl() > 0) {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
if ($this->sessionCache->hasItem($ipKey)) {
/* ip address corresponds to valid existing session */
$id = $this->sessionCache->getItem($ipKey)->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
public function onKernelRequest(RequestEvent $event): void
{
if ($this->config->ipTtl() <= 0) {
return;
}
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
try {
if (! $this->sessionCache->hasItem($ipKey)) {
return;
}
/* ip address corresponds to valid existing session */
$item = $this->sessionCache->getItem($ipKey);
if (! $item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
$id = $item->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse($this->authSuccessResponse($id, $this->config));
} catch (InvalidArgumentException $e) {
/* cache failure — fail closed (don't authenticate) */
$this->logger->error("cache error in AllowListener: {$e->getMessage()}");
}
}
}
+19 -12
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -18,7 +19,8 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class InterceptListener {
final readonly class InterceptListener
{
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
@@ -27,11 +29,13 @@ final readonly class InterceptListener {
private ConfigBag $config,
private DomainInterface $domainManager,
private Environment $twig,
) {}
) {
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
#[AsEventListener(priority: 55)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
/* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited,
* nor submitting login credentials; so redirect or present the login page now */
@@ -40,7 +44,9 @@ final readonly class InterceptListener {
) {
/* host matches base-domain of auth, but not on auth subdomain, redirect */
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
$event->setResponse(new Response('', Response::HTTP_SEE_OTHER,
$event->setResponse(new Response(
'',
Response::HTTP_SEE_OTHER,
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
));
} else {
@@ -50,22 +56,23 @@ final readonly class InterceptListener {
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
]);
$hasCookie = (bool) $event->getRequest()->cookies->get(
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
$this->sessionCookieName($this->domainManager)
);
$event->setResponse($this->pruneInvalidCookie(new Response($content,
Response::HTTP_UNAUTHORIZED, ['Content-Type' => 'text/html']
$event->setResponse($this->pruneInvalidCookie(new Response(
$content,
Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html']
), $hasCookie, $event->getRequest()->getHost()));
}
}
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response {
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response
{
if ($hasCookie) {
/* input here must match LoginListener::setCookie() */
$response->headers->clearCookie(
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
$this->sessionCookieName($this->domainManager),
'/',
/* if using central auth, only set the domain if the host matches */
$this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
$this->sessionCookieDomain($this->domainManager, $host),
true,
true,
Cookie::SAMESITE_STRICT
+28 -11
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -23,7 +24,17 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class LoginListener {
/**
* Handles login attempts via X-Preauth header (AJAX) or POST form submission.
*
* CSRF Protection: The nonce field serves as CSRF protection for the POST form
* path. Nonces are server-generated, single-use, and have a 120-second TTL.
* An attacker cannot forge a POST request without first loading the login page
* to obtain a valid nonce, which requires being on the auth subdomain.
* For the AJAX (header) path, the nonce is embedded in the base64url payload.
*/
final readonly class LoginListener
{
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
@@ -32,18 +43,19 @@ final readonly class LoginListener {
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private Environment $twig,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
) {
$this->rateLimiter = $rateLimiter;
}
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
$payload = null;
$response = null;
@@ -51,7 +63,7 @@ final readonly class LoginListener {
/* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName());
$payload = Payload::decode($data);
} else if ($event->getRequest()->isMethod(Request::METHOD_POST) &&
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
) {
/* if request is a POST to the auth-subdomain */
@@ -76,18 +88,23 @@ final readonly class LoginListener {
$limitReached = $this->logFailure($event->getRequest());
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
$event->setResponse($this->makeFailedResponse(
$limitReached,
$payload?->json ?? true,
$event->getRequest()->getHost(),
$this->makeCacheKey($payload?->id ?? '')
));
}
private function logFailure(Request $request): bool {
private function logFailure(Request $request): bool
{
$limiter = $this->rateLimiter->create($request->getClientIp());
return ($limiter->consume(1)->getRemainingTokens() < 1);
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response {
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
{
if ($limited) {
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
: Response::HTTP_TOO_MANY_REQUESTS;
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use App\Service\PublicPathMatcherInterface;
use App\Trait\HasLoggerTrait;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
/**
* Allows rate-limited unauthenticated access to configured public paths.
*
* Runs at priority 84 — after AcceptListener (99) and AllowListener (88)
* so authenticated users bypass this listener entirely, but before
* RejectListener (77) and LoginListener (66) so public traffic is not
* subject to the login rate limiter.
*
* When the request path matches a configured public path pattern:
* - If within rate limit → 200 OK (no Remote-User header)
* - If over rate limit → 429 Too Many Requests with Retry-After header
*
* Non-matching paths fall through to the normal auth flow.
*/
final readonly class PublicAccessListener
{
use HasLoggerTrait;
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private PublicPathMatcherInterface $pathMatcher,
private DomainInterface $domainManager,
private Environment $twig,
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
}
/** @throws SyntaxError|RuntimeError|LoaderError */
#[AsEventListener(priority: 84)]
public function onKernelRequest(RequestEvent $event): void
{
if ($this->pathMatcher->isEmpty()) {
return;
}
$request = $event->getRequest();
$host = $request->getHost();
$path = $request->getPathInfo();
// Never treat the auth subdomain itself as public
if ($this->domainManager->getAuthSubdomain() === $host) {
return;
}
if (! $this->pathMatcher->matches($host, $path)) {
return;
}
// Path is public — apply rate limiting
$limiter = $this->rateLimiter->create($request->getClientIp());
$limit = $limiter->consume(1);
if ($limit->isAccepted()) {
$this->logger->debug("public access granted: {$request->getClientIp()} -> $path");
$event->setResponse(new Response(
'',
Response::HTTP_OK,
[
'Content-Type' => 'text/plain',
'Retry-After' => (string) $limit->getRemainingTokens(),
],
));
} else {
$retryAfter = $limit->getRetryAfter()?->getTimestamp() - time();
$retryAfter = max(1, $retryAfter);
$this->logger->debug("public access rate-limited: {$request->getClientIp()} -> $path");
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response(
$html,
Response::HTTP_TOO_MANY_REQUESTS,
[
'Content-Type' => 'text/html',
'Retry-After' => (string) $retryAfter,
],
));
}
}
}
+10 -5
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -16,15 +17,16 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class RejectListener {
final readonly class RejectListener
{
use HasLoggerTrait;
use StringTrait;
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private ConfigBag $config,
private Environment $twig,
private ConfigBag $config,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
@@ -32,13 +34,16 @@ final readonly class RejectListener {
/** @throws SyntaxError|RuntimeError|LoaderError */
#[AsEventListener(priority: 77)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
/* check if they have made too many failed login attempts */
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
if ($limiter->consume(0)->getRemainingTokens() < 1) {
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response($html, ($this->config->teapot()
$event->setResponse(new Response(
$html,
($this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
['Content-Type' => 'text/html']
));
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
* Adds security-related HTTP response headers to all responses.
* These headers help protect against XSS, clickjacking, MIME-type
* sniffing, and referrer leakage.
*/
final readonly class SecurityHeadersListener
{
public function __construct(
private DomainInterface $domainManager,
) {
}
#[AsEventListener(priority: 0)]
public function onKernelResponse(ResponseEvent $event): void
{
if (! $event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$headers = $response->headers;
/* prevent MIME-type sniffing */
$headers->set('X-Content-Type-Options', 'nosniff');
/* prevent clickjacking — this app is never framed */
$headers->set('X-Frame-Options', 'DENY');
/* control referrer information sent to other sites */
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
/* Content-Security-Policy — the login page uses inline styles
* and scripts (via Twig includes), so we allow 'unsafe-inline'
* for those. No external resources are loaded.
*
* When subdomain redirection is off (or the request is not on
* the auth subdomain), the login form is served inline on the
* protected host and submission is performed via a same-origin
* fetch() call in _script.html.twig. That fetch is blocked by
* the default 'none' policy, so we add connect-src 'self' only
* in that case — the least privilege needed to make the form
* work. On the auth subdomain the form POSTs normally and no
* inline script is included, so the stricter policy applies. */
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
if ($inlineScript) {
$csp .= " connect-src 'self';";
}
$headers->set('Content-Security-Policy', $csp);
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
$headers->set('Strict-Transport-Security', 'max-age=31536000');
}
}
+46 -21
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -10,7 +11,8 @@ use Psr\Cache\InvalidArgumentException;
/* we must *NOT* store the key-list item or values within this object
* because it can change from outside this object instance */
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
final readonly class MonitorCacheKeys implements CacheItemPoolInterface
{
private const string KEY_LIST = '__key_list';
private const string CHANGE_LIST = '__chg_list';
public const int UPDATED = 1;
@@ -19,11 +21,12 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
private CacheItemPoolInterface $cache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $cache) {
public function __construct(CacheItemPoolInterface $cache)
{
$this->cache = $cache;
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
foreach ($items as $item) {
if ( ! $item->isHit()) {
if (! $item->isHit()) {
$this->initialize();
break;
}
@@ -31,7 +34,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
private function initialize(): void {
private function initialize(): void
{
$keyList = $this->cache->getItem(self::KEY_LIST);
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$keyList->set([]);
@@ -42,42 +46,51 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
public function getKeys(): array {
public function getKeys(): array
{
$keyList = $this->cache->getItem(self::KEY_LIST);
return array_keys($keyList->get() ?? []);
}
/** @throws InvalidArgumentException */
public function getChanges(): array {
public function getChanges(): array
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? [];
}
/** @throws InvalidArgumentException */
public function markClean(): void {
public function markClean(): void
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeList->set([]);
$this->cache->save($changeList);
}
public function getItem(string $key): CacheItemInterface {
/** @throws InvalidArgumentException */
public function getItem(string $key): CacheItemInterface
{
return $this->cache->getItem($key);
}
/** @return CacheItemInterface[]
* @throws InvalidArgumentException */
public function getItems(array $keys = []): iterable {
public function getItems(array $keys = []): iterable
{
return $this->cache->getItems($keys);
}
public function hasItem(string $key): bool {
/** @throws InvalidArgumentException */
public function hasItem(string $key): bool
{
return $this->cache->hasItem($key);
}
/** @throws InvalidArgumentException */
public function clear(): bool {
public function clear(): bool
{
/* only bother clearing the pool if it is not empty */
if ( ! empty($this->getKeys())) {
if (! empty($this->getKeys())) {
$response = $this->cache->clear();
$this->initialize();
@@ -86,7 +99,9 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return true;
}
public function deleteItem(string $key): bool {
/** @throws InvalidArgumentException */
public function deleteItem(string $key): bool
{
$this->isValid($key);
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -101,7 +116,9 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return $this->cache->deleteItem($key);
}
public function deleteItems(array $keys): bool {
/** @throws InvalidArgumentException */
public function deleteItems(array $keys): bool
{
$this->allValid($keys);
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -119,23 +136,28 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
public function save(CacheItemInterface $item): bool {
public function save(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->save($item);
}
/** @throws InvalidArgumentException */
public function saveDeferred(CacheItemInterface $item): bool {
public function saveDeferred(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->saveDeferred($item);
}
public function commit(): bool {
/** @throws InvalidArgumentException */
public function commit(): bool
{
return $this->cache->commit();
}
/** @throws InvalidArgumentException|OutOfBoundsException */
private function update(CacheItemInterface $item): void {
private function update(CacheItemInterface $item): void
{
$this->isValid($item->getKey());
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -147,7 +169,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws OutOfBoundsException */
private function isValid(string $key): void {
private function isValid(string $key): void
{
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
throw new OutOfBoundsException(
'Can not modify the private key or change lists'
@@ -156,7 +179,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws OutOfBoundsException */
private function allValid(array $keys): void {
private function allValid(array $keys): void
{
if (in_array(self::KEY_LIST, $keys, true) ||
in_array(self::CHANGE_LIST, $keys, true)
) {
@@ -167,7 +191,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void {
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeValues = $changeList->get();
$changeValues[$key] = $code;
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -9,7 +10,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/* need autoconfigure so we get it from the service container in Kernel->boot() */
#[Autoconfigure(public: true)]
final readonly class PersistCache {
final readonly class PersistCache
{
private MonitorCacheKeys $sessionCache;
private MonitorCacheKeys $sessionStorage;
@@ -23,7 +25,8 @@ final readonly class PersistCache {
}
/** @throws InvalidArgumentException */
public function boot(): void {
public function boot(): void
{
/* the caches are considered warm as soon as they are not empty */
if (empty($this->sessionCache->getKeys())) {
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
@@ -36,7 +39,8 @@ final readonly class PersistCache {
}
/** @throws InvalidArgumentException */
public function persist(): void {
public function persist(): void
{
/* we only need to persist the changes made to the cache (if any) */
$changes = $this->sessionCache->getChanges();
if ($changes) {
+5 -3
View File
@@ -1,19 +1,21 @@
<?php
namespace App\Service;
declare(strict_types=1);
namespace App\Service;
use Exception;
use Psr\Cache\InvalidArgumentException;
/** backup-codes are caseinsensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
interface BackupCodeInterface {
interface BackupCodeInterface
{
/** generate a set of backup-codes and return them
* @param int $count Number of codes to generate
* @return string[] Generated backup codes
* @throws InvalidArgumentException|Exception */
public function generate(int $count = 0): array;
public function generate(int $count = 10): array;
/** @throws InvalidArgumentException */
public function expire(): void;
+20 -10
View File
@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\AppConstants;
use App\MonitorCacheKeys;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
@@ -14,19 +16,21 @@ use App\Trait\GetTotpTrait;
/** backup-codes are caseinsensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
final readonly class BackupCodeManager implements BackupCodeInterface {
final readonly class BackupCodeManager implements BackupCodeInterface
{
use GetTotpTrait;
use HasLoggerTrait;
use StringTrait;
private const int DEFAULT_COUNT = 10;
/* php base_convert() will break if given too long of an input */
const int MAX_LENGTH = 64;
public const int MAX_LENGTH = 64;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $sessionCache) {
public function __construct(CacheItemPoolInterface $sessionCache)
{
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
@@ -34,7 +38,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
* @param int $count Number of codes to generate
* @return string[] Generated backup codes
* @throws InvalidArgumentException|Exception */
public function generate(int $count = self::DEFAULT_COUNT): array {
public function generate(int $count = self::DEFAULT_COUNT): array
{
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
$codes = [];
for ($i = 0; $i < $count; $i++) {
@@ -49,7 +54,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
}
/** @throws InvalidArgumentException */
public function expire(): void {
public function expire(): void
{
$itemsToRemove = [];
foreach ($this->sessionCache->getKeys() as $key) {
if (str_starts_with($key, 'backup_')) {
@@ -65,11 +71,12 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
* @param string $code Code supplied by the client
* @return bool true if the code is valid and unused
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool {
public function verifyAndConsume(string $code): bool
{
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
$this->logger->debug("checking backup code '{$backupKey}': " . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
$this->logger->debug('checking backup code: ' . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
if ($backupItem->isHit() && $backupItem->get()) {
$this->logger->debug("valid backup code");
/* mark backup code as spent */
@@ -77,7 +84,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
));
$this->sessionCache->save($backupItem);
@@ -87,7 +95,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
}
/** @throws InvalidArgumentException */
private function saveCodes(array $codes): void {
private function saveCodes(array $codes): void
{
foreach ($codes as $code) {
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
/* mark backup code as ready */
@@ -95,7 +104,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
));
$this->sessionCache->saveDeferred($backupItem);
}
+4 -1
View File
@@ -1,8 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Service;
interface DomainInterface {
interface DomainInterface
{
/** IE: "auth.example.com" or null if not using a separate subdomain
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string;
+107 -20
View File
@@ -1,28 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class DomainManager implements DomainInterface {
final readonly class DomainManager implements DomainInterface
{
/* top-level-domains which are known to have multiple parts */
private const array TLD = [
'ai' => ['com','net','off','org'],
'am' => ['radio'],
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za'],
'at' => ['ac','co','gv','or'],
'au' => ['com','net','org','edu','gov','asn','id'],
'az' => ['com','net','org'],
'bd' => ['com','net','org','gov','mil','ac'],
'br' => ['com','net','org','gov','mil','eco','emp','g12','ind','inf','rec','tur','tv','edu','far','gov','gru','jor','leg','lec','med','nom','not','ppg','pro','psi','pub','slg','srv','tec','tmp','vip','vlog','wiki','zlg'],
'by' => ['com','net','org','gov','mil','of'],
'ca' => ['ab','bc','mb','nb','nf','nl','ns','nt','nu','on','pe','qc','sk','yk'],
'cc' => [],
'cn' => ['com','net','org','gov','edu','ac','bj','sh','tj','cq','he','sx','nm','ln','jl','hl','js','zj','ah','fj','jx','sd','ha','hb','hn','gd','gx','hi','sc','gz','yn','sn','gs','qh','nx','xj','tw','hk','mo'],
'co' => ['com','net','org','gov','mil','edu','arts','firm','info','int','nom','rec','web'],
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za','au','bh','bo','cn','ec','eg','gt','hk','hn','il','in','jp','kr','kw','lb','lv','my','mx','ng','ni','np','pe','pf','pg','ph','pk','pl','pr','py','sa','sg','sv','tr','tw','ua','uy','ve','vn','ye'],
'de' => ['com'],
'dk' => ['co'],
'ec' => ['com','net','org','gov','mil','edu','fin','med','pro'],
'ee' => ['com','org','pri'],
'eg' => ['com','net','org','gov','edu','mil'],
'es' => ['com','nom','org','edu','gob'],
'eu' => [],
'fi' => ['aland'],
'fm' => ['radio'],
'fr' => ['com','nom','tm','asso','gouv','pol'],
'ge' => ['com','net','org','edu','gov','mil'],
'gg' => ['co','net','org'],
'in' => ['co','firm','gen','ind','net','org'],
'gr' => ['com','net','org','gov','edu','mil'],
'hk' => ['com','net','org','gov','edu','idv'],
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'],
'id' => ['ac','biz','co','desa','go','mil','my','net','or','sch','web'],
'ie' => ['gov'],
'il' => ['ac','co','gov','idf','k12','muni','net','org'],
'in' => ['co','firm','gen','ind','net','org','ac','edu','res','gov','mil'],
'iq' => ['com','net','org','gov','edu','mil'],
'ir' => ['ac','co','gov','id','net','org','sch'],
'is' => ['net','com','org','edu','gov','int'],
'it' => ['ab','ag','al','an','ao','ap','aq','ar','at','av','ba','bg','bi','bl','bn','bo','br','bs','bt','bz','ca','cb','ce','ch','cl','cn','co','cr','cs','ct','cz','en','fc','fe','fg','fi','fm','fr','ge','go','gr','im','is','kr','lc','le','li','lo','lt','lu','mb','mc','me','mi','mn','mo','ms','mt','na','no','nu','or','pa','pc','pd','pe','pg','pi','pn','po','pr','pt','pu','pv','pz','re','rg','ri','rm','rn','ro','sa','si','so','sp','sr','ss','su','sv','ta','te','tn','to','tp','tr','ts','tv','ud','va','vb','vc','ve','vi','vr','vt','vv','edu','gov','abruzzo','basilicata','calabria','campania','emilia-romagna','friuli-ve-giulia','lazio','liguria','lombardia','marche','molise','piemonte','puglia','sardegna','sicilia','toscana','trentino-a-adige','umbria','valle-aosta','veneto'],
'je' => ['co','net','org'],
'mx' => ['com','net','org'],
'net' => ['gb','hu','in','jp','se','uk'],
'nz' => ['co','net','org'],
'org' => ['ae','us'],
'ph' => ['com','net','org'],
'se' => ['com'],
'uk' => ['co','me','org'],
'jo' => ['com','net','org','gov','edu','mil','sch'],
'jp' => ['ac','ad','co','ed','go','gr','lg','ne','or'],
'ke' => ['co','ne','or','ac','go','me','mobi','info','sc','pro'],
'kg' => ['com','net','org','gov','mil','edu'],
'kr' => ['ac','co','go','hs','kg','mil','ms','ne','or','pe','re','seoul','busan','daegu','incheon','gwangju','daejeon','ulsan','gyeonggi','gangwon','chungbuk','chungnam','jeonbuk','jeonnam','gyeongbuk','gyeongnam','jeju','sejong'],
'kz' => ['com','net','org','edu','gov','mil'],
'li' => [],
'lt' => ['gov'],
'lv' => ['com','net','org','edu','gov','mil','id','asn','conf'],
'ly' => ['com','net','org','gov','edu','sch','med','id'],
'ma' => ['co','net','org','gov','press','ac'],
'mk' => ['com','net','org','edu','gov','inf','name','pro'],
'mx' => ['com','net','org','gov','edu','mil'],
'my' => ['com','net','org','gov','edu','mil','name'],
'na' => ['com','net','org','alt','edu','gov','mil','pro'],
'net' => ['gb','hu','in','jp','se','uk','cn','nz'],
'ng' => ['com','net','org','gov','edu','mil','sch','name','gov'],
'ni' => ['ac','co','com','edu','gob','mil','net','nom','org'],
'nl' => ['bv','co'],
'no' => ['fhs','folkebibl','kommune','mil','stat','priv','vgs','dep','kommune'],
'nz' => ['co','net','org','ac','geek','gen','maori','school','parliament','govt','health','mil','crii','archie','geek','govt','health','maori','school'],
'om' => ['com','net','org','gov','edu','med','mil','sch'],
'org' => ['ae','us','lu'],
'pe' => ['com','net','org','gob','edu','mil','nom'],
'ph' => ['com','net','org','gov','edu','mil'],
'pk' => ['com','net','org','fam','biz','edu','gov','web'],
'pl' => ['com','net','org','aid','agro','atm','auto','biz','edu','gmina','gsm','info','mail','miasta','media','mil','ngo','nom','pc','powiat','priv','realestate','rel','sex','shop','sklep','sos','szkola','targi','tm','tourism','travel','turystyka','gov','ap','augov','bedzin','bialystok','bielawa','bierun','boleslawiec','bydgoszcz','bytom','cieszyn','czeladz','czest','dlugoleka','elblag','elk','glogow','gniezno','gorlice','gorzow','grodzisk','grudziadz','ilk','jaworzno','jelenia-gora','jgora','kalisz','kazimierz-dolny','karpacz','kartuzy','kaszuby','katowice','kepno','ketrzyn','klodzko','kobierzyce','kolobrzeg','konin','konskowola','krapkowice','krakow','krasnik','krasno','krosniewice','kutno','lapy','lebork','legnica','lezajsk','limanowa','lomza','lowicz','lubin','lukow','malbork','malopolska','mazowsze','mazury','mielec','milicz','mielno','mragowo','naklo','nowaruda','nysa','olawa','olecko','olkusz','olsztyn','opoczno','opole','ostrowiec','ostroleka','ostrowwlkp','pila','pisz','podhale','podlasie','polkowice','pomorze','pomorse','prochowice','pruszkow','przeworsk','pulawy','rabka','rawa-maz','rybnik','rzeszow','sanok','sejny','siedlce','slask','slupsk','sosnowiec','stalowa-wola','skoczow','starachowice','stargard','suwalki','swidnica','swiebodzin','swinoujscie','szczecin','szczytno','tarnobrzeg','tgory','turek','tychy','ustka','walbrzych','warmia','warszawa','waw','wegrow','wielun','wlocl','wloclawek','wodzislaw','wolomin','wroclaw','zachpomor','zagan','zarow','zgora','zgorzelec','plug'],
'pr' => ['ac','co','edu','gov','info','island','pro','net','org'],
'pt' => ['com','net','org','gov','edu','int','publ'],
'py' => ['com','net','org','gov','edu','mil','co'],
'qa' => ['com','net','org','gov','edu','mil','sch','name'],
'ro' => ['com','net','org','nom','rec','info','arts','com','firm','tm','www','store','nt','ngo','pro','tm','com','arts','rec','store','info','nom','nt','org','shop','firm','www','rest','travel','transport','tourism','press','media','medical','med','law','jobs','inst','individual','insinfo','guru','fit','engineering','expert','energy','economy','dot','dog','dev','design','dem','dental','craft','corp','consulting','construction','company','com','club','cloud','coach','city','cinema','church','chat','casino','cars','care','cards','broke','blog','bio','bid','band','auto','audio','attorney','apartments','app','art','archi','architects','arena','architects','associates','attorney','auction','auto','baby','band','bank','bar','bargains','beer','berlin','best','bet','bid','bike','bingo','bio','black','blog','blue','boats','bond','boo','book','boutique','build','builders','business','buzz','cab','cafe','call','cam','camp','capital','care','careers','cars','cash','casino','catering','center','ceo','ceramics','cfd','ch','chat','church','city','claims','cleaning','click','clinic','clothing','cloud','club','coach','codes','coffee','college','community','company','computer','condos','construction','consulting','contact','cooking','cool','country','courses','cpa','craft','credit','creditcard','cricket','cruise','cuisinella','cymru','dabur','dance','date','dating','deals','degree','delivery','democrat','dental','design','dev','diamonds','diet','digital','direct','directory','discount','dog','domains','doos','download','ec','edu','education','energy','engineering','enterprises','equipment','estate','events','exchange','expert','exposed','express','fail','faith','family','fan','farm','fashion','film','finance','financial','fish','fit','fitness','flights','florist','flowers','football','forex','forsale','foundation','fun','fund','furniture','futbol','fyi','gal','gallery','game','garden','gift','gifts','gives','glass','global','gold','golf','graphics','gratis','green','gripe','group','guru','health','healthcare','help','helsinki','here','hiphop','hiv','holdings','holiday','homes','horse','host','hosting','house','how','immo','immobilien','in','industries','info','ink','institute','insure','international','investments','irish','jewelry','kaufen','kids','kim','kitchen','kiwi','kred','land','law','lawyer','legal','lgbt','lifestyle','lighting','limited','limo','link','live','loan','loans','lol','london','love','ltd','ltda','luxury','maison','management','market','marketing','markets','media','memorial','men','menu','miami','mobi','moda','moe','mom','money','monster','mortgage','movie','nagoya','name','navy','net','network','news','ngo','ninja','nyc','observer','okinawa','one','ong','onl','online','ooo','org','organic','osaka','paris','partners','parts','party','photo','photography','photos','pics','pictures','pink','pizza','place','plumbing','plus','poker','porn','press','pro','productions','properties','property','pub','qpon','realtor','realty','recipes','red','rehab','reise','reisen','rent','rentals','repair','report','rest','restaurant','review','reviews','rich','rip','rocks','rodeo','run','saarland','sale','salon','sarl','save','saxo','school','schule','science','services','sex','sexy','sg','shop','shopping','show','singles','site','ski','soccer','social','software','solar','solutions','space','store','stream','studio','study','style','supplies','supply','support','surgery','systems','tax','taxi','team','tech','technology','tennis','thai','tips','tires','tirol','today','tokyo','tools','top','tour','tours','town','toys','trade','trading','training','travel','tube','university','uno','vacations','vegas','ventures','vet','viajes','video','villas','vin','vision','vlaanderen','vodka','vote','voting','voto','voyage','wales','watch','webcam','website','wedding','wien','wiki','win','wine','work','works','world','wtf','xxx','xyz','yoga','yokohama','zone'],
'ru' => ['ac','com','edu','int','net','org','pp','adygeya','altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey'],
'sa' => ['com','net','org','gov','med','pub','edu','sch'],
'sb' => ['com','net','org','edu','gov'],
'sc' => ['com','net','org','gov','edu'],
'se' => ['a','ac','b','bd','brand','c','d','e','f','fh','fhsk','fhv','g','h','i','k','komforb','kommunal','komvux','kunskapsforb','l','lanbib','m','n','naturbruksgymn','o','org','p','parti','pp','press','r','s','t','tm','u','v','w','x','y','z'],
'sg' => ['com','net','org','gov','edu','per'],
'sh' => ['com','net','org','gov','mil','edu'],
'sk' => ['co','com','edu','gov','mil','net','org','nfo'],
'st' => ['co','com','consulado','edu','embaixada','gov','mil','net','org','principe','saotome','store'],
'su' => ['abkhazia','adygeya','ak', 'altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey','com','net','org','gov','pp','edu'],
'sv' => ['com','edu','gob','org','red'],
'sy' => ['com','net','org','gov','edu','mil','name'],
'th' => ['ac','co','go','in','mi','net','or'],
'tj' => ['ac','biz','co','com','edu','gov','go','info','int','mil','name','net','nic','nom','org','pro','test','web'],
'tn' => ['agrinet','com','defense','edunet','ens','fin','gov','ind','info','intl','min','nat','net','org','perso','rnrt','rns','rnu','tourism','turen'],
'tr' => ['com','net','org','gov','biz','info','mil','edu','tv','bbs','k12','pol','bel','dr','gen','av','bbs','k12','name','tel','nc','web','tsk','bel','pol','edu'],
'tw' => ['com','net','org','edu','gov','mil','idv','game','ebiz','club','gnu'],
'ua' => ['com','net','org','edu','gov','in','at','cn','crimea','dn','dnepropetrovsk','donetsk','dp','if','ivano-frankivsk','kh','kharkov','kherson','khmelnitskiy','kiev','kirovograd','km','kr','ks','kv','lg','lt','lugansk','lutsk','lv','lviv','mk','mk.ua','mykolaiv','net','nikolaev','od','odessa','pl','poltava','rovno','rv','sebastopol','sm','sumy','te','ternopil','uz','uzhgorod','vinnica','vn','volyn','yalta','zaporizhzhe','zhitomir','zp','zt'],
'uk' => ['co','me','org','ltd','plc','net','sch','ac','gov','nhs','police','mod','nhs','parliament'],
'us' => ['ak','al','ar','as','az','ca','co','ct','dc','de','fl','ga','gu','hi','ia','id','il','in','ks','ky','la','ma','md','me','mi','mn','mo','ms','mt','nc','nd','ne','nh','nj','nm','nv','ny','oh','ok','or','pa','pr','ri','sc','sd','tn','tx','ut','vi','vt','va','wa','wi','wv','wy','dni','fed','isa','kids','nsn'],
'uy' => ['com','net','org','gub','mil','edu'],
've' => ['co','com','edu','gob','info','net','org','web'],
'vn' => ['com','net','org','edu','gov','int','ac','biz','info','name','pro','health'],
'yu' => ['ac','co','edu','gov','org'],
'za' => ['ac','alt','co','edu','gov','law','mil','net','ngo','nom','org','school','tm','web'],
];
private bool $subdomainRedirect;
@@ -38,7 +119,8 @@ final readonly class DomainManager implements DomainInterface {
/** IE: "auth.example.com" or null if not using a separate subdomain
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string {
public function getAuthSubdomain(): ?string
{
if ($this->authBase()) {
return $this->authSubdomain;
}
@@ -48,7 +130,8 @@ final readonly class DomainManager implements DomainInterface {
/** check if given url is an acceptable url for redirection
* @param string $url Where we are thinking of sending the user
* @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool {
public function validReturn(string $url): bool
{
/* ensure url is valid and, when using an auth subdomain,
* that the url host matches the base domain */
if (!filter_var($url, FILTER_VALIDATE_URL)) {
@@ -57,7 +140,7 @@ final readonly class DomainManager implements DomainInterface {
if ($this->authBase()) {
$host = parse_url($url, PHP_URL_HOST);
if ($host === null) {
if ($host === null || $host === false || $host === '') {
return false;
}
/* do not send the user to another domain */
@@ -70,7 +153,8 @@ final readonly class DomainManager implements DomainInterface {
/** check if host-base matches auth-base
* @param string $host
* @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);
$authBase = $this->baseDomain($this->authSubdomain);
return $this->subdomainRedirect && $this->authSubdomain &&
@@ -79,7 +163,8 @@ final readonly class DomainManager implements DomainInterface {
/** IE: "example.com" if central auth is something like "auth.example.com"
* @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string {
public function authBase(): ?string
{
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
return $this->baseDomain($this->authSubdomain);
}
@@ -91,13 +176,14 @@ final readonly class DomainManager implements DomainInterface {
* things like "localhost" and "8.8.8.8" will return null
* @param string $host ip, localhost, or domain with zero or more subdomains
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
private function baseDomain(string $host): ?string {
private function baseDomain(string $host): ?string
{
/* if host is an ip address (or localhost), leave it as is */
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
return null;
}
$parts = explode('.', $host);
$parts = explode('.', strtolower($host));
$keep = $this->baseLength($parts);
$parts = array_slice($parts, -$keep);
return implode('.', $parts);
@@ -106,12 +192,13 @@ final readonly class DomainManager implements DomainInterface {
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
* @param string[] $parts pieces of a domain split by "." dot
* @return int typically 2 but sometimes 3 */
private function baseLength(array $parts): int {
private function baseLength(array $parts): int
{
$length = count($parts);
$baseLength = min(2, $length);
/* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[$length-1]]) &&
in_array($parts[$length-2], self::TLD[$parts[$length-1]], true)
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
) {
$baseLength = min(3, $length);
}
+4 -1
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\Payload;
@@ -7,7 +9,8 @@ use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface LoginInterface {
interface LoginInterface
{
/** @throws InvalidArgumentException */
public function checkToken(Payload $payload, Request $request): ?Response;
}
+15 -17
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -18,7 +19,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
final readonly class LoginManager implements LoginInterface {
final readonly class LoginManager implements LoginInterface
{
use CookieNameTrait;
use GetTotpTrait;
use MakeNonceTrait;
@@ -28,7 +30,7 @@ final readonly class LoginManager implements LoginInterface {
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $sessionCache,
private BackupCodeInterface $backupCodeManager,
private DomainInterface $domainManager,
) {
@@ -36,14 +38,15 @@ final readonly class LoginManager implements LoginInterface {
}
/** @throws InvalidArgumentException */
public function checkToken(Payload $payload, Request $request): ?Response {
public function checkToken(Payload $payload, Request $request): ?Response
{
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie;
}
if ($this->getTotp()->verify($payload->token, null, 10) ||
if ($this->getTotp()->verify($payload->token, null, 1) ||
$this->backupCodeManager->verifyAndConsume($payload->token)
) {
/* token is correct (TOTP or Backup) */
@@ -60,16 +63,13 @@ final readonly class LoginManager implements LoginInterface {
$cleanId = $this->makeCacheKey($payload->id);
/* if they just want this one page, return ok, to grant them access */
$response = new Response("hi $cleanId", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $cleanId,
]);
$response = $this->authSuccessResponse($cleanId, $this->config);
if ($payload->scope !== Scope::None) {
/* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} else if ($payload->scope === Scope::Ip) {
} elseif ($payload->scope === Scope::Ip) {
$this->setIp($cleanId, $request->getClientIp());
}
@@ -104,7 +104,8 @@ final readonly class LoginManager implements LoginInterface {
}
/** @throws InvalidArgumentException */
private function setCookie(string $id, string $host): Cookie {
private function setCookie(string $id, string $host): Cookie
{
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
@@ -119,16 +120,12 @@ final readonly class LoginManager implements LoginInterface {
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionCache->save($sessionCookie);
/* when using subdomain-auth we have to use a different cookie name, as the
* "__Host-Http-" prefix we normally use does not allow domain to be set */
/* changes here must be reflected in InterceptListener::pruneInvalidCookie() */
return Cookie::create(
name: $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
name: $this->sessionCookieName($this->domainManager),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
path: '/',
/* if using central auth, only set the domain if the host matches */
domain: $this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
domain: $this->sessionCookieDomain($this->domainManager, $host),
secure: true,
httpOnly: true,
sameSite: Cookie::SAMESITE_STRICT,
@@ -136,7 +133,8 @@ final readonly class LoginManager implements LoginInterface {
}
/** @throws InvalidArgumentException */
private function setIp(string $id, string $ip): void {
private function setIp(string $id, string $ip): void
{
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Matches request paths against configured public path patterns.
*
* Patterns are provided as a comma-separated string in the format:
* /path/pattern, host.example.com/path/pattern, or a mix.
*
* Wildcards:
* - * matches any characters within a single path segment (not crossing /)
* - ** matches any characters including / (crosses path segments)
*
* Query strings are not part of the pattern — matching is against the
* path only.
*/
final readonly class PublicPathMatcher implements PublicPathMatcherInterface
{
/** @var list<array{host: ?string, regex: string}> */
private array $patterns;
public function __construct(
#[Autowire('%app.public_paths%')] string $publicPaths,
) {
$this->patterns = $this->parse($publicPaths);
}
public function isEmpty(): bool
{
return $this->patterns === [];
}
public function matches(string $host, string $path): bool
{
if ($this->patterns === []) {
return false;
}
$host = strtolower($host);
foreach ($this->patterns as $entry) {
if ($entry['host'] !== null && $entry['host'] !== $host) {
continue;
}
if (preg_match($entry['regex'], $path) === 1) {
return true;
}
}
return false;
}
/**
* Parse the comma-separated PUBLIC_PATHS string into pattern entries.
*
* @return list<array{host: ?string, regex: string}>
*/
private function parse(string $publicPaths): array
{
if (trim($publicPaths) === '') {
return [];
}
$patterns = [];
foreach (explode(',', $publicPaths) as $raw) {
$entry = trim($raw);
if ($entry === '') {
continue;
}
// Check for a host prefix (anything before the first /)
$host = null;
$path = $entry;
if (preg_match('/^([a-z0-9.-]+)(\/.*)$/i', $entry, $m)) {
$host = strtolower($m[1]);
$path = $m[2];
}
// Validate path starts with /
if (!str_starts_with($path, '/')) {
continue;
}
$patterns[] = [
'host' => $host,
'regex' => $this->compilePattern($path),
];
}
return $patterns;
}
/**
* Convert a wildcard path pattern into a regex string.
*
* Star becomes a character class matching one or more non-slash chars.
* Double-star at end of pattern matches zero or more of any char.
* Double-star followed by slash matches zero or more path segments.
* Other characters are escaped as literal regex.
*/
private function compilePattern(string $pattern): string
{
$regex = '';
$length = strlen($pattern);
$i = 0;
while ($i < $length) {
// Check for ** (must be at current position)
if ($i + 1 < $length && $pattern[$i] === '*' && $pattern[$i + 1] === '*') {
$i += 2;
if ($i >= $length) {
// ** at end of pattern: zero or more chars including /
$regex .= '.*';
} elseif ($pattern[$i] === '/') {
// /**/ in middle: zero or more intermediate segments
$regex .= '(?:.*/)?';
$i += 1; // skip the / after **
} else {
// ** not followed by / or end, treat as .*
$regex .= '.*';
}
} elseif ($pattern[$i] === '*') {
$regex .= '[^/]+';
$i += 1;
} else {
$regex .= preg_quote($pattern[$i], '#');
$i += 1;
}
}
return '#^' . $regex . '$#';
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Matches request paths against configured public path patterns.
*
* Patterns support simple wildcards:
* - `*` matches any characters within a single path segment (not crossing `/`)
* - `**` matches any characters including `/` (crosses path segments)
*
* Patterns may optionally include a host prefix (e.g. `example.com/public/**`).
* When no host prefix is given, the pattern matches on any host.
*/
interface PublicPathMatcherInterface
{
/**
* Returns true if the given host and path match any configured public pattern.
*
* @param string $host The request host (e.g. "code.example.com")
* @param string $path The request path (e.g. "/public/repo/issues")
*/
public function matches(string $host, string $path): bool;
/**
* Returns true if no public paths are configured (feature is disabled).
*/
public function isEmpty(): bool;
}
+30 -4
View File
@@ -1,22 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait CookieNameTrait {
use App\Service\DomainInterface;
trait CookieNameTrait
{
private const string COOKIE_NAME = '__Host-Http-Preauth';
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private const string HEADER_NAME = 'X-Preauth';
final protected function cookieName(): string {
final protected function cookieName(): string
{
return static::COOKIE_NAME;
}
final protected function authCookieName(): string {
final protected function authCookieName(): string
{
return static::AUTH_COOKIE_NAME;
}
final protected function headerName(): string {
final protected function headerName(): string
{
return static::HEADER_NAME;
}
/**
* Returns the appropriate cookie name based on whether central auth is active.
* Uses the __Host- prefix for single-domain mode (no Domain attribute),
* and a non-prefixed name for central auth (Domain attribute required).
*/
final protected function sessionCookieName(DomainInterface $domainManager): string
{
return $domainManager->authBase() ? $this->authCookieName() : $this->cookieName();
}
/**
* Returns the cookie domain for central auth mode, or null for single-domain.
* The domain is only set when the host matches the auth base domain.
*/
final protected function sessionCookieDomain(DomainInterface $domainManager, string $host): ?string
{
return $domainManager->matchesAuth($host) ? $domainManager->authBase() : null;
}
}
+11 -5
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -6,24 +7,29 @@ namespace App\Trait;
use App\ConfigBag;
use OTPHP\Factory;
use OTPHP\TOTPInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
trait GetTotpTrait {
trait GetTotpTrait
{
protected readonly ConfigBag $config;
#[Required]
public function setConfig(ConfigBag $config): void {
public function setConfig(ConfigBag $config): void
{
$this->config = $config;
}
protected function getTotp(): TOTPInterface {
protected function getTotp(): TOTPInterface
{
$otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(), $this->config->clock()
$this->config->totpUri(),
$this->config->clock()
);
if ($otp instanceof TOTPInterface) {
return $otp;
}
throw new HttpException(500, 'Internal Server Exception');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
}
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -6,11 +7,13 @@ namespace App\Trait;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\Attribute\Required;
trait HasLoggerTrait {
trait HasLoggerTrait
{
protected readonly LoggerInterface $logger;
#[Required]
public function setLogger(LoggerInterface $logger): void {
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -10,7 +11,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait {
trait MakeNonceTrait
{
use HasLoggerTrait;
use StringTrait;
@@ -21,12 +23,14 @@ trait MakeNonceTrait {
protected readonly CacheItemPoolInterface $nonceCache;
#[Required]
public function setNonceCache(CacheItemPoolInterface $nonceCache): void {
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
{
$this->nonceCache = $nonceCache;
}
/** @throws InvalidArgumentException|Exception */
protected function makeNonce(int $retries = 3): string {
protected function makeNonce(int $retries = 3): string
{
/* convert raw binary into base64url */
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
+43 -3
View File
@@ -1,13 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait StringTrait {
use App\AppConstants;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use Symfony\Component\HttpFoundation\Response;
trait StringTrait
{
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
public function makeCacheKey(string $name): string {
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
public function makeCacheKey(string $name): string
{
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, AppConstants::MAX_INPUT_LENGTH);
}
/**
* Build the plain-text success response body and headers for an
* authenticated request. The body is a simple greeting that includes
* the session id. The Remote-User header is set (or omitted) based on
* the configured remote-user mode.
*/
public function authSuccessResponse(string $id, ConfigBag $config): Response
{
$headers = ['Content-Type' => 'text/plain'];
$headerValue = $this->resolveRemoteUser($id, $config);
if ($headerValue !== null) {
$headers['Remote-User'] = $headerValue;
}
return new Response("hi $id", headers: $headers);
}
/**
* Resolve the Remote-User header value based on the configured mode.
* Returns null when the header should not be sent.
*/
private function resolveRemoteUser(string $id, ConfigBag $config): ?string
{
return match ($config->remoteUserMode()) {
RemoteUserMode::Session => $id,
RemoteUserMode::Static => $config->remoteUserStatic(),
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
RemoteUserMode::None => null,
};
}
}
+17 -8
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -11,14 +12,17 @@ use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
final readonly class Utilities {
final readonly class Utilities
{
public function __construct(
private ClockInterface $clock,
private CacheItemPoolInterface $appPool,
) {}
) {
}
/** @throws InvalidArgumentException */
public function loadTotp(): string {
public function loadTotp(): string
{
/* user forgot to set their TOTP_URI in the environment */
if ($this->appPool->hasItem('totp')) {
$totp = $this->appPool->getItem('totp')->get();
@@ -31,7 +35,8 @@ final readonly class Utilities {
}
/** @throws InvalidArgumentException */
private function makeTotp(): string {
private function makeTotp(): string
{
/* we have not stored a totp into the app cache yet */
$totpObj = TOTP::generate($this->clock);
$totpObj->setLabel('Preauth-TOTP');
@@ -41,21 +46,25 @@ final readonly class Utilities {
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
));
$this->appPool->save($totpItem);
return $totp;
}
private function showTotp(string $totp): void {
private function showTotp(string $totp): void
{
$writer = new Writer(new PlainTextRenderer());
file_put_contents(
'php://stderr', <<<RAW
'php://stderr',
<<<RAW
{$writer->writeString($totp)}
$totp
loading TOTP, because the env is not set, please copy above into TOTP_URI
RAW, FILE_APPEND
RAW,
FILE_APPEND
);
}
}
+12
View File
@@ -1,4 +1,16 @@
{
"friendsofphp/php-cs-fixer": {
"version": "3.95",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.39",
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"phpunit/phpunit": {
"version": "13.2",
"recipe": {
+1 -3
View File
@@ -53,9 +53,7 @@ form.addEventListener('submit', (event) => {
console.log('got html response');
{% endif -%}
response.text().then((html) => {
document.open();
document.write(html);
document.close();
document.documentElement.innerHTML = html;
}).catch((error) => {
console.log('failed to get html from response');
console.log(error);
+3
View File
@@ -1,5 +1,8 @@
{% extends 'base.html.twig' %}
{# The nonce field serves dual purpose: replay prevention AND CSRF protection.
An attacker cannot forge a POST request without a valid nonce, which is
generated server-side per page load and tied to the user's session. #}
{% block content %}
<h1>{{ env.title }}</h1>
<p id="preauth-message">{{ message|default }}</p>
+54 -25
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
@@ -14,8 +15,8 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
* travels through RejectListener -> LoginListener -> AllowListener ->
* AcceptListener -> InterceptListener and the services they orchestrate.
*/
final class AuthenticationFlowTest extends WebTestCase {
final class AuthenticationFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
private const string COOKIE_NAME = '__Host-Http-Preauth';
@@ -32,13 +33,15 @@ final class AuthenticationFlowTest extends WebTestCase {
return $client;
}
private function validTotpCode(): string {
private function validTotpCode(): string
{
// the app uses the real system clock, so generate the code for now()
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
private function encodePayload(array $data): string {
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
@@ -59,7 +62,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── unauthenticated access ──────────────────────────────────────── */
public function testUnauthenticatedRequestShowsLoginPage(): void {
public function testUnauthenticatedRequestShowsLoginPage(): void
{
$client = static::createClient();
$client->request('GET', '/');
@@ -71,7 +75,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSelectorExists('input[name="totp"]');
}
public function testLoginPageContainsGeneratedNonce(): void {
public function testLoginPageContainsGeneratedNonce(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -81,7 +86,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
}
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void {
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -93,7 +99,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── successful TOTP login ────────────────────────────────────────── */
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void {
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
{
$client = static::createClient();
// first, grab a valid nonce from the login page
@@ -125,7 +132,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
}
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void {
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -147,7 +155,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void {
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -167,7 +176,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
}
public function testAuthenticatedCookieAccessAfterLogin(): void {
public function testAuthenticatedCookieAccessAfterLogin(): void
{
$client = static::createClient();
// login
@@ -204,7 +214,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame('dave', $response->headers->get('Remote-User'));
}
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void {
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -229,7 +240,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void {
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -254,7 +266,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertNotEmpty($body['nonce']);
}
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void {
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -275,7 +288,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSelectorExists('form#preauth-form');
}
public function testFailedLoginWithSpentNonceIsRejected(): void {
public function testFailedLoginWithSpentNonceIsRejected(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -309,7 +323,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testFailedLoginWithInvalidNonceIsRejected(): void {
public function testFailedLoginWithInvalidNonceIsRejected(): void
{
$client = static::createClient();
// skip fetching a real nonce; use one that was never stored
@@ -327,7 +342,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── invalid payload ──────────────────────────────────────────────── */
public function testInvalidHeaderPayloadReturnsUnauthorized(): void {
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
{
$client = static::createClient();
$client->request('GET', '/', [], [], [
@@ -338,7 +354,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void {
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
{
$client = static::createClient();
// payload missing token
@@ -353,7 +370,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── invalid cookie ───────────────────────────────────────────────── */
public function testInvalidCookieIsClearedAndLoginPageShown(): void {
public function testInvalidCookieIsClearedAndLoginPageShown(): void
{
$client = static::createClient();
// the cookie must be set via the CookieJar so that the HttpFoundation
@@ -361,8 +379,15 @@ final class AuthenticationFlowTest extends WebTestCase {
// not parsed by Request::create)
$client->getCookieJar()->set(
new \Symfony\Component\BrowserKit\Cookie(
self::COOKIE_NAME, 'invalid-ulid-value',
null, '/', 'localhost', true, true, false, 'Strict',
self::COOKIE_NAME,
'invalid-ulid-value',
null,
'/',
'localhost',
true,
true,
false,
'Strict',
)
);
@@ -383,7 +408,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── backup code authentication ───────────────────────────────────── */
public function testBackupCodeAuthenticationWorks(): void {
public function testBackupCodeAuthenticationWorks(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -407,7 +433,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame(303, $client->getResponse()->getStatusCode());
}
public function testConsumedBackupCodeCannotBeReused(): void {
public function testConsumedBackupCodeCannotBeReused(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -442,7 +469,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── return URL handling ──────────────────────────────────────────── */
public function testSuccessfulLoginWithValidReturnUrl(): void {
public function testSuccessfulLoginWithValidReturnUrl(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/?return=https://example.com/app');
@@ -460,7 +488,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void {
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/?return=not-a-url');
+211
View File
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use OTPHP\TOTP;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* End-to-end functional tests for the public rate-limited access feature.
*
* The test environment (phpunit.dist.xml) configures:
* PUBLIC_PATHS=/public/**
* PUBLIC_BURST_COUNT=3, PUBLIC_BURST_TIME=60
* PUBLIC_UPPER_COUNT=10000 (effectively unlimited for test purposes)
*
* @covers \App\Listener\PublicAccessListener
* @covers \App\Service\PublicPathMatcher
*/
final class PublicAccessFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
protected static function createClient(array $options = [], array $server = []): KernelBrowser
{
$client = parent::createClient($options, $server);
$client->disableReboot();
return $client;
}
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
/* ── public path accessible without auth ───────────────────────────── */
public function testPublicPathAccessibleWithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/public/some-repo');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
// No Remote-User header for public access
self::assertFalse($response->headers->has('Remote-User'));
}
public function testPublicPathWithQuerystringAccessible(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo?tab=issues&page=2');
self::assertSame(200, $client->getResponse()->getStatusCode());
}
public function testDeepPublicPathAccessible(): void
{
$client = static::createClient();
$client->request('GET', '/public/org/repo/issues/42');
self::assertSame(200, $client->getResponse()->getStatusCode());
}
/* ── non-public path requires auth ─────────────────────────────────── */
public function testNonPublicPathShowsLoginPage(): void
{
$client = static::createClient();
$client->request('GET', '/private/settings');
self::assertSame(401, $client->getResponse()->getStatusCode());
self::assertSelectorExists('form#preauth-form');
}
public function testRootPathShowsLoginPage(): void
{
$client = static::createClient();
$client->request('GET', '/');
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testExactPublicPathWithoutSlashNotMatched(): void
{
// /public/** does NOT match /public (no trailing content)
$client = static::createClient();
$client->request('GET', '/public');
self::assertSame(401, $client->getResponse()->getStatusCode());
}
/* ── rate limiting ─────────────────────────────────────────────────── */
public function testRateLimitEnforcedAfterBurstExceeded(): void
{
$client = static::createClient();
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
for ($i = 0; $i < 3; $i++) {
$client->request('GET', '/public/repo');
self::assertSame(
200,
$client->getResponse()->getStatusCode(),
"Request $i should have been allowed"
);
}
// 4th request should be rate limited
$client->request('GET', '/public/repo');
$response = $client->getResponse();
self::assertSame(429, $response->getStatusCode());
self::assertTrue($response->headers->has('Retry-After'));
$retryAfter = (int) $response->headers->get('Retry-After');
self::assertGreaterThan(0, $retryAfter);
}
/* ── authenticated user bypasses public rate limiter ───────────────── */
public function testAuthenticatedUserBypassesPublicRateLimit(): void
{
$client = static::createClient();
// First, exhaust the public rate limiter
for ($i = 0; $i < 4; $i++) {
$client->request('GET', '/public/repo');
}
// Confirm rate limit is in effect
$client->request('GET', '/public/repo');
self::assertSame(429, $client->getResponse()->getStatusCode());
// Now log in — the cookie should let us bypass public rate limiting
$client->getCookieJar()->clear();
$crawler = $client->request('GET', '/private');
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
$client->request('GET', '/private', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
// Now visit a public path while authenticated — should get 200
// (AcceptListener runs before PublicAccessListener, so the public
// rate limiter is never consulted)
$client->request('GET', '/public/repo');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
// Authenticated users get Remote-User header
self::assertSame('alice', $response->headers->get('Remote-User'));
}
/* ── 200 response has correct content type ─────────────────────────── */
public function testPublicAccessResponseIsPlainText(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
}
/* ── 429 response renders error template ───────────────────────────── */
public function testRateLimitedResponseRendersErrorTemplate(): void
{
$client = static::createClient();
// Exhaust rate limit
for ($i = 0; $i < 4; $i++) {
$client->request('GET', '/public/repo');
}
$response = $client->getResponse();
self::assertSame(429, $response->getStatusCode());
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
$content = $response->getContent();
// The error template renders either teapot or too-many-requests content
// Default test env has TEAPOT=true
self::assertNotEmpty($content);
}
/* ── security headers still applied to public responses ────────────── */
public function testSecurityHeadersOnPublicAccess(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
$response = $client->getResponse();
// SecurityHeadersListener runs on all main-request responses
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
}
}
+46 -21
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
@@ -17,11 +18,13 @@ use Twig\Loader\FilesystemLoader;
* Helpers for constructing the collaborators that the kernel listeners
* depend on, without booting the full Symfony container.
*/
trait ListenerTestHelper {
trait ListenerTestHelper
{
use TotpTestHelper;
/** 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');
$twig = new Environment($loader, ['strict_variables' => true]);
// the templates reference a global `env` object; supply one with the
@@ -49,32 +52,43 @@ trait ListenerTestHelper {
* A RateLimiterFactoryInterface whose created limiter returns a RateLimit
* with the given remaining tokens.
*/
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface {
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
{
$limiter = $this->makeLimiter($remainingTokens);
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter) {}
public function create(?string $key = null): LimiterInterface {
return new class ($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter)
{
}
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
}
};
}
private function makeLimiter(int $remainingTokens): LimiterInterface {
private function makeLimiter(int $remainingTokens): LimiterInterface
{
$rateLimit = new RateLimit(
$remainingTokens,
new \DateTimeImmutable('+10 seconds'),
$remainingTokens > 0,
10,
);
return new class($rateLimit) implements LimiterInterface {
public function __construct(private RateLimit $rateLimit) {}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation {
return new class ($rateLimit) implements LimiterInterface {
public function __construct(private RateLimit $rateLimit)
{
}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
}
public function consume(int $tokens = 1): RateLimit {
public function consume(int $tokens = 1): RateLimit
{
return $this->rateLimit;
}
public function reset(): void {}
public function reset(): void
{
}
};
}
@@ -82,14 +96,19 @@ trait ListenerTestHelper {
* A factory whose limiter tracks how many consume(1) calls were made and
* reports the limit as reached only after $threshold failures.
*/
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface {
$limiter = new class($threshold) implements LimiterInterface {
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
{
$limiter = new class ($threshold) implements LimiterInterface {
private int $consumed = 0;
public function __construct(private int $threshold) {}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation {
public function __construct(private int $threshold)
{
}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
}
public function consume(int $tokens = 1): RateLimit {
public function consume(int $tokens = 1): RateLimit
{
$this->consumed += $tokens;
$remaining = max(0, $this->threshold - $this->consumed);
return new RateLimit(
@@ -99,11 +118,17 @@ trait ListenerTestHelper {
$this->threshold,
);
}
public function reset(): void { $this->consumed = 0; }
public function reset(): void
{
$this->consumed = 0;
}
};
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter) {}
public function create(?string $key = null): LimiterInterface {
return new class ($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter)
{
}
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
}
};
+35 -12
View File
@@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Utilities;
use DateTimeImmutable;
use OTPHP\TOTP;
@@ -17,7 +19,8 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
* Provides a deterministic TOTP fixture plus a frozen clock and ready-made
* ConfigBag / cache-pool helpers for tests that exercise TOTP-dependent code.
*/
trait TotpTestHelper {
trait TotpTestHelper
{
/** well-known Base32 test secret (JBSWY3DPEHPK3PXP) */
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
@@ -25,30 +28,37 @@ trait TotpTestHelper {
protected const string FROZEN_TIME = '2025-06-15 12:00:00';
/** Frozen clock that always returns the same instant. */
private function frozenClock(): PsrClockInterface {
private function frozenClock(): PsrClockInterface
{
$time = self::FROZEN_TIME;
return new class($time) implements PsrClockInterface {
public function __construct(private string $time) {}
public function now(): DateTimeImmutable {
return new class ($time) implements PsrClockInterface {
public function __construct(private string $time)
{
}
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
}
};
}
/** Provisioning URI built from the well-known secret + frozen clock. */
private function totpUri(): string {
private function totpUri(): string
{
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
$totp->setLabel('Test-TOTP');
return $totp->getProvisioningUri();
}
/** The TOTP code that is valid at the frozen timestamp. */
private function validTotpCode(): string {
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now();
}
/** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */
private function emptyPool(): CacheItemPoolInterface {
private function emptyPool(): CacheItemPoolInterface
{
return new ArrayAdapter();
}
@@ -63,13 +73,25 @@ trait TotpTestHelper {
string $errorMessage = 'Error',
string $teapotTitle = 'Teapot',
string $tooManyTitle = 'Too Many',
string $remoteUserMode = 'session',
string $remoteUserStatic = 'authenticated',
string $remoteUserMap = '',
): ConfigBag {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
return new ConfigBag(
$utilities, $clock,
$cookieTtl, $this->totpUri(), $ipTtl, $teapot,
$errorMessage, $teapotTitle, $tooManyTitle,
$utilities,
$clock,
$cookieTtl,
$this->totpUri(),
$ipTtl,
$teapot,
$errorMessage,
$teapotTitle,
$tooManyTitle,
$remoteUserMode,
$remoteUserStatic,
$remoteUserMap,
);
}
@@ -77,7 +99,8 @@ trait TotpTestHelper {
* Minimal Utilities stub that never triggers TOTP generation when
* a non-empty totpUri is supplied to ConfigBag.
*/
private function createUtilities(?PsrClockInterface $clock = null): Utilities {
private function createUtilities(?PsrClockInterface $clock = null): Utilities
{
$clock ??= $this->frozenClock();
$cache = $this->createStub(CacheItemPoolInterface::class);
$cache->method('hasItem')->willReturn(false);
+3 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests;
@@ -27,10 +28,10 @@ class TestKernel extends AppKernel
{
parent::build($container);
$container->addCompilerPass(new class implements CompilerPassInterface {
$container->addCompilerPass(new class () implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) {
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
if ($container->hasDefinition($poolId)) {
$container->getDefinition($poolId)->clearTag('kernel.reset');
}
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -6,8 +7,10 @@ namespace App\Tests\Unit;
use App\Clock;
use PHPUnit\Framework\TestCase;
final class ClockTest extends TestCase {
public function testNowReturnsDateTimeImmutable(): void {
final class ClockTest extends TestCase
{
public function testNowReturnsDateTimeImmutable(): void
{
$clock = new Clock();
$before = new \DateTimeImmutable();
$now = $clock->now();
@@ -1,34 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Command;
use App\Command\GenerateBackupCodesCommand;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Console\Tester\CommandTester;
final class GenerateBackupCodesCommandTest extends TestCase {
final class GenerateBackupCodesCommandTest extends TestCase
{
/** PersistCache is final, so construct a real one backed by ArrayAdapters. */
private function makePersistCache(): PersistCache {
private function makePersistCache(): PersistCache
{
return new PersistCache(new ArrayAdapter(), new ArrayAdapter());
}
/** A stub BackupCodeInterface that returns the given codes from generate(). */
private function makeManagerStub(array $generatedCodes): BackupCodeInterface {
private function makeManagerStub(array $generatedCodes): BackupCodeInterface
{
$manager = $this->createStub(BackupCodeInterface::class);
$manager->method('generate')->willReturn($generatedCodes);
return $manager;
}
public function testGenerateDefaultCountOutputsCodes(): void {
public function testGenerateDefaultCountOutputsCodes(): void
{
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub($codes), $this->makePersistCache()
$this->makeManagerStub($codes),
$this->makePersistCache()
);
$command->setName('app:generate-backup-codes');
@@ -42,7 +48,8 @@ final class GenerateBackupCodesCommandTest extends TestCase {
}
}
public function testGenerateSpecificCountPassesCountToManager(): void {
public function testGenerateSpecificCountPassesCountToManager(): void
{
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
->method('generate')
@@ -58,7 +65,8 @@ final class GenerateBackupCodesCommandTest extends TestCase {
self::assertSame(0, $exit);
}
public function testDefaultCountArgumentIsTen(): void {
public function testDefaultCountArgumentIsTen(): void
{
// the configured default for the count argument should be 10
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
@@ -76,13 +84,15 @@ final class GenerateBackupCodesCommandTest extends TestCase {
$this->addToAssertionCount(1);
}
public function testBootsAndPersistsCache(): void {
public function testBootsAndPersistsCache(): void
{
// PersistCache is final and can't be mocked, but we can verify the
// command runs end-to-end with a real instance; boot()/persist()
// are invoked implicitly. A successful exit confirms both were called
// without throwing.
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['code1']), $this->makePersistCache()
$this->makeManagerStub(['code1']),
$this->makePersistCache()
);
$command->setName('app:generate-backup-codes');
@@ -92,22 +102,25 @@ final class GenerateBackupCodesCommandTest extends TestCase {
self::assertSame(0, $exit);
}
public function testZeroCodesOutputsNothing(): void {
public function testZeroCodesThrowsException(): void
{
// count must be a positive integer — zero is rejected
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]), $this->makePersistCache()
$this->makeManagerStub([]),
$this->makePersistCache()
);
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$exit = $tester->execute(['count' => 0]);
self::assertSame(0, $exit);
self::assertSame('', trim($tester->getDisplay()));
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class);
$tester->execute(['count' => 0]);
}
public function testCommandNameAndDescriptionAreConfigured(): void {
public function testCommandNameAndDescriptionAreConfigured(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]), $this->makePersistCache()
$this->makeManagerStub(['dummy']),
$this->makePersistCache()
);
// configuring via the Application runs the protected configure()
$app = new \Symfony\Component\Console\Application();
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Tests\Support\TotpTestHelper;
use PHPUnit\Framework\TestCase;
final class ConfigBagRemoteUserTest extends TestCase
{
use TotpTestHelper;
public function testDefaultRemoteUserModeIsSession(): void
{
$config = $this->makeConfig();
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
}
public function testStaticMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
self::assertSame(RemoteUserMode::Static, $config->remoteUserMode());
self::assertSame('authenticated', $config->remoteUserStatic());
}
public function testMappedMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
self::assertSame(RemoteUserMode::Mapped, $config->remoteUserMode());
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testNoneMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'none');
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
}
public function testInvalidModeFallsBackToSession(): void
{
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
}
public function testEmptyMapReturnsEmptyArray(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
self::assertSame([], $config->remoteUserMap());
}
public function testMapParsesWithWhitespace(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
remoteUserMap: ' alice : admin , bob : user ',
);
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testMapIgnoresInvalidEntries(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
remoteUserMap: 'alice:admin,noColon,bob:user',
);
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testMapPreservesColonsInValue(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
remoteUserMap: 'alice:admin:extra',
);
self::assertSame(['alice' => 'admin:extra'], $config->remoteUserMap());
}
}
+61 -19
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -10,8 +11,10 @@ use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface;
final class ConfigBagTest extends TestCase {
private function createUtilities(?string $totp = null): Utilities {
final class ConfigBagTest extends TestCase
{
private function createUtilities(?string $totp = null): Utilities
{
$clock = $this->createStub(ClockInterface::class);
$cache = $this->createStub(CacheItemPoolInterface::class);
@@ -28,14 +31,24 @@ final class ConfigBagTest extends TestCase {
return new Utilities($clock, $cache);
}
public function testGettersWithExplicitValues(): void {
public function testGettersWithExplicitValues(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://totp/test', 1800, true,
'Error!', 'Teapot!', 'Too Many!'
$utilities,
$clock,
3600,
'otpauth://totp/test',
1800,
true,
'Error!',
'Teapot!',
'Too Many!',
'session',
'authenticated',
'',
);
self::assertSame($clock, $config->clock());
@@ -48,43 +61,72 @@ final class ConfigBagTest extends TestCase {
self::assertSame('Too Many!', $config->tooManyTitle());
}
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void {
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities('fallback-totp');
$config = new ConfigBag(
$utilities, $clock,
3600, '', 1800, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'',
1800,
false,
'Error',
'Teapot',
'Too Many',
'session',
'authenticated',
'',
);
self::assertSame('fallback-totp', $config->totpUri());
}
public function testIpTtlFallsBackToNullWhenZero(): void {
public function testIpTtlFallsBackToNullWhenZero(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://totp/test', 0, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'otpauth://totp/test',
0,
false,
'Error',
'Teapot',
'Too Many',
'session',
'authenticated',
'',
);
self::assertNull($config->ipTtl());
}
public function testIpTtlFallsBackToNullWhenNull(): void {
public function testIpTtlFallsBackToNullWhenNull(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://totp/test', null, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'otpauth://totp/test',
null,
false,
'Error',
'Teapot',
'Too Many',
'session',
'authenticated',
'',
);
self::assertNull($config->ipTtl());
}
}
+55 -27
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Data;
@@ -8,12 +9,15 @@ use App\Enum\Scope;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\InputBag;
final class PayloadTest extends TestCase {
private static function b64u(string $data): string {
final class PayloadTest extends TestCase
{
private static function b64u(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
public function testDecodeValidBase64Url(): void {
public function testDecodeValidBase64Url(): void
{
$data = json_encode([
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
'json' => true, 'scope' => 'cookie',
@@ -28,41 +32,50 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testDecodeInvalidBase64UrlReturnsNull(): void {
public function testDecodeInvalidBase64UrlReturnsNull(): void
{
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
}
public function testDecodeNonObjectJsonReturnsNull(): void {
public function testDecodeNonObjectJsonReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('"just a string"')));
}
public function testDecodeInvalidJsonReturnsNull(): void {
public function testDecodeInvalidJsonReturnsNull(): void
{
// valid base64url but invalid JSON
self::assertNull(Payload::decode(self::b64u('{invalid json')));
}
public function testDecodeJsonArrayReturnsNull(): void {
public function testDecodeJsonArrayReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
}
public function testDecodeJsonNullReturnsNull(): void {
public function testDecodeJsonNullReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('null')));
}
public function testDecodeJsonBooleanReturnsNull(): void {
public function testDecodeJsonBooleanReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('true')));
self::assertNull(Payload::decode(self::b64u('false')));
}
public function testDecodeJsonNumberReturnsNull(): void {
public function testDecodeJsonNumberReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('42')));
}
public function testDecodeEmptyStringReturnsNull(): void {
public function testDecodeEmptyStringReturnsNull(): void
{
self::assertNull(Payload::decode(''));
}
public function testLoadWithValidInputBag(): void {
public function testLoadWithValidInputBag(): void
{
$input = new InputBag([
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
]);
@@ -76,28 +89,33 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testLoadMissingUsernameReturnsNull(): void {
public function testLoadMissingUsernameReturnsNull(): void
{
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
self::assertNull(Payload::load($input));
}
public function testLoadMissingNonceReturnsNull(): void {
public function testLoadMissingNonceReturnsNull(): void
{
$input = new InputBag(['username' => 'u', 'totp' => 't']);
self::assertNull(Payload::load($input));
}
public function testLoadMissingTotpReturnsNull(): void {
public function testLoadMissingTotpReturnsNull(): void
{
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
self::assertNull(Payload::load($input));
}
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void {
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void
{
// has() returns true for all, but create() rejects empty values
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
self::assertNull(Payload::load($input));
}
public function testCreateWithValidData(): void {
public function testCreateWithValidData(): void
{
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => false, 'scope' => 'ip',
@@ -112,13 +130,15 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Ip, $payload->scope);
}
public function testCreateWithDefaultScope(): void {
public function testCreateWithDefaultScope(): void
{
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data);
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithInvalidScopeFallsBackToCookie(): void {
public function testCreateWithInvalidScopeFallsBackToCookie(): void
{
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'scope' => 'admin',
@@ -127,13 +147,15 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithMissingJsonDefaultsToTrue(): void {
public function testCreateWithMissingJsonDefaultsToTrue(): void
{
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data);
self::assertTrue($payload->json);
}
public function testCreateWithNoneScopeSetsJsonFalse(): void {
public function testCreateWithNoneScopeSetsJsonFalse(): void
{
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => true, 'scope' => 'none',
@@ -143,27 +165,32 @@ final class PayloadTest extends TestCase {
self::assertFalse($payload->json);
}
public function testCreateWithEmptyIdReturnsNull(): void {
public function testCreateWithEmptyIdReturnsNull(): void
{
$data = (object)['id' => '', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithWhitespaceIdReturnsNull(): void {
public function testCreateWithWhitespaceIdReturnsNull(): void
{
$data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithEmptyTokenReturnsNull(): void {
public function testCreateWithEmptyTokenReturnsNull(): void
{
$data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithEmptyNonceReturnsNull(): void {
public function testCreateWithEmptyNonceReturnsNull(): void
{
$data = (object)['id' => 'u', 'token' => 't', 'nonce' => ''];
self::assertNull(Payload::create($data));
}
public function testCreateTrimsAndTruncatesFields(): void {
public function testCreateTrimsAndTruncatesFields(): void
{
$long = str_repeat('a', 200);
$data = (object)[
'id' => ' ' . $long . ' ',
@@ -177,7 +204,8 @@ final class PayloadTest extends TestCase {
self::assertSame($expected, $payload->nonce);
}
public function testToString(): void {
public function testToString(): void
{
$payload = new Payload();
$payload->id = 'u';
$payload->token = 't';
+9 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Enum;
@@ -6,20 +7,24 @@ namespace App\Tests\Unit\Enum;
use App\Enum\Scope;
use PHPUnit\Framework\TestCase;
final class ScopeTest extends TestCase {
public function testCases(): void {
final class ScopeTest extends TestCase
{
public function testCases(): void
{
self::assertSame('cookie', Scope::Cookie->value);
self::assertSame('ip', Scope::Ip->value);
self::assertSame('none', Scope::None->value);
}
public function testTryFromValid(): void {
public function testTryFromValid(): void
{
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
self::assertSame(Scope::None, Scope::tryFrom('none'));
}
public function testTryFromInvalid(): void {
public function testTryFromInvalid(): void
{
self::assertNull(Scope::tryFrom('invalid'));
self::assertNull(Scope::tryFrom(''));
}
+149 -9
View File
@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\ConfigBag;
use App\Listener\AcceptListener;
use App\Service\DomainManager;
use App\Tests\Support\TotpTestHelper;
@@ -14,19 +16,25 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class AcceptListenerTest extends TestCase {
final class AcceptListenerTest extends TestCase
{
use TotpTestHelper;
private const string COOKIE_NAME = '__Host-Http-Preauth';
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private function makeListener(ArrayAdapter $pool, DomainManager $domainManager): AcceptListener {
$listener = new AcceptListener($pool, $domainManager);
private function makeListener(
ArrayAdapter $pool,
DomainManager $domainManager,
?ConfigBag $config = null,
): AcceptListener {
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
$listener->setLogger(new NullLogger());
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class),
$request,
@@ -36,7 +44,8 @@ final class AcceptListenerTest extends TestCase {
/* ── valid cookie session ─────────────────────────────────────────── */
public function testValidCookieSetsResponseWithRemoteUser(): void {
public function testValidCookieSetsResponseWithRemoteUser(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
@@ -59,7 +68,8 @@ final class AcceptListenerTest extends TestCase {
self::assertSame('text/plain', $response->headers->get('Content-Type'));
}
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void {
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
@@ -81,7 +91,8 @@ final class AcceptListenerTest extends TestCase {
/* ── negative cases ───────────────────────────────────────────────── */
public function testNoCookieSetsNoResponse(): void {
public function testNoCookieSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($pool, $domainManager);
@@ -92,7 +103,8 @@ final class AcceptListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testCookieWithoutSessionSetsNoResponse(): void {
public function testCookieWithoutSessionSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($pool, $domainManager);
@@ -106,7 +118,8 @@ final class AcceptListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testEmptyCookieValueSetsNoResponse(): void {
public function testEmptyCookieValueSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($pool, $domainManager);
@@ -121,4 +134,131 @@ final class AcceptListenerTest extends TestCase {
// empty cookie value should not be treated as a valid session
self::assertFalse($event->hasResponse());
}
/* ── Remote-User header modes ─────────────────────────────────────── */
public function testRemoteUserSessionModeSendsSessionId(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'session'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserStaticModeSendsFixedValue(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserMappedModeSendsMappedValue(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserMappedModeFallsBackToSessionIdWhenNotInMap(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('unknown_user');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserNoneModeOmitsHeader(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'none'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertFalse($event->getResponse()->headers->has('Remote-User'));
}
}
+15 -7
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -13,16 +14,19 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class AllowListenerTest extends TestCase {
final class AllowListenerTest extends TestCase
{
use TotpTestHelper;
private function makeListener(ArrayAdapter $pool, ConfigBag $config): AllowListener {
private function makeListener(ArrayAdapter $pool, ConfigBag $config): AllowListener
{
$listener = new AllowListener($pool, $config);
$listener->setLogger(new NullLogger());
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -30,7 +34,8 @@ final class AllowListenerTest extends TestCase {
);
}
public function testValidIpSessionSetsResponseWithRemoteUser(): void {
public function testValidIpSessionSetsResponseWithRemoteUser(): void
{
$pool = new ArrayAdapter();
$item = $pool->getItem('ip_1.2.3.4');
$item->set('carol');
@@ -50,7 +55,8 @@ final class AllowListenerTest extends TestCase {
self::assertSame('text/plain', $response->headers->get('Content-Type'));
}
public function testNoIpSessionSetsNoResponse(): void {
public function testNoIpSessionSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 1800);
$listener = $this->makeListener($pool, $config);
@@ -62,7 +68,8 @@ final class AllowListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testIpAccessDisabledSetsNoResponse(): void {
public function testIpAccessDisabledSetsNoResponse(): void
{
$pool = new ArrayAdapter();
// even though there's a stored session, ip access is disabled
$item = $pool->getItem('ip_1.2.3.4');
@@ -79,7 +86,8 @@ final class AllowListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testIpAccessDisabledDoesNotCheckCache(): void {
public function testIpAccessDisabledDoesNotCheckCache(): void
{
$pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 0);
$listener = $this->makeListener($pool, $config);
+23 -11
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -16,7 +17,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class InterceptListenerTest extends TestCase {
final class InterceptListenerTest extends TestCase
{
use ListenerTestHelper;
private const string COOKIE_NAME = '__Host-Http-Preauth';
@@ -36,7 +38,8 @@ final class InterceptListenerTest extends TestCase {
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -46,7 +49,8 @@ final class InterceptListenerTest extends TestCase {
/* ── central-auth redirect branch ─────────────────────────────────── */
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void {
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -64,7 +68,8 @@ final class InterceptListenerTest extends TestCase {
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
}
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void {
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -81,7 +86,8 @@ final class InterceptListenerTest extends TestCase {
/* ── login page rendering branch ──────────────────────────────────── */
public function testPresentsLoginPageWithUnauthorizedStatus(): void {
public function testPresentsLoginPageWithUnauthorizedStatus(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -99,7 +105,8 @@ final class InterceptListenerTest extends TestCase {
self::assertStringContainsString('name="nonce"', $content);
}
public function testGeneratedNonceIsStoredInCache(): void {
public function testGeneratedNonceIsStoredInCache(): void
{
$nonceCache = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager, $nonceCache);
@@ -119,7 +126,8 @@ final class InterceptListenerTest extends TestCase {
self::assertTrue(count($nonceCache->getValues()) > 0);
}
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void {
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -132,7 +140,8 @@ final class InterceptListenerTest extends TestCase {
self::assertStringContainsString('method="post"', $content);
}
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void {
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -147,7 +156,8 @@ final class InterceptListenerTest extends TestCase {
/* ── invalid cookie pruning ───────────────────────────────────────── */
public function testInvalidCookieIsClearedWhenPresent(): void {
public function testInvalidCookieIsClearedWhenPresent(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -171,7 +181,8 @@ final class InterceptListenerTest extends TestCase {
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
}
public function testNoCookieClearingWhenNoCookiePresent(): void {
public function testNoCookieClearingWhenNoCookiePresent(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -183,7 +194,8 @@ final class InterceptListenerTest extends TestCase {
self::assertSame([], $response->headers->getCookies());
}
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void {
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
+29 -14
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -17,7 +18,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class LoginListenerTest extends TestCase {
final class LoginListenerTest extends TestCase
{
use ListenerTestHelper;
private const string HEADER_NAME = 'X-Preauth';
@@ -39,7 +41,8 @@ final class LoginListenerTest extends TestCase {
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -48,14 +51,16 @@ final class LoginListenerTest extends TestCase {
}
/** 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);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
/* ── no login attempt ─────────────────────────────────────────────── */
public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void {
public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void
{
$listener = $this->makeListener();
$request = Request::create('https://example.com/', 'GET');
@@ -65,7 +70,8 @@ final class LoginListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void {
public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void
{
// POST only counts as a login attempt when on the auth subdomain
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener(domainManager: $domainManager);
@@ -79,7 +85,8 @@ final class LoginListenerTest extends TestCase {
/* ── successful login via header ──────────────────────────────────── */
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void {
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void
{
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn($expected);
@@ -99,7 +106,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame($expected, $event->getResponse());
}
public function testSuccessfulLoginViaPostToAuthSubdomain(): void {
public function testSuccessfulLoginViaPostToAuthSubdomain(): void
{
$expected = new Response('hi bob', 303, ['Location' => '/']);
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn($expected);
@@ -120,7 +128,8 @@ final class LoginListenerTest extends TestCase {
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void {
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -148,7 +157,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame('alice', $body['username']);
}
public function testFailedLoginHtmlResponseWhenJsonFalse(): void {
public function testFailedLoginHtmlResponseWhenJsonFalse(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -169,7 +179,8 @@ final class LoginListenerTest extends TestCase {
self::assertStringContainsString('<form', $response->getContent());
}
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void {
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -194,7 +205,8 @@ final class LoginListenerTest extends TestCase {
/* ── rate-limited (blocked) login ─────────────────────────────────── */
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void {
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -220,7 +232,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame('Teapot', $body['message']);
}
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void {
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -252,7 +265,8 @@ final class LoginListenerTest extends TestCase {
/* ── invalid payload handling ─────────────────────────────────────── */
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void {
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void
{
$loginManager = $this->createMock(LoginInterface::class);
// checkToken should not be called with a null payload
$loginManager->expects(self::never())->method('checkToken');
@@ -272,7 +286,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
}
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void {
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void
{
$loginManager = $this->createMock(LoginInterface::class);
$loginManager->expects(self::never())->method('checkToken');
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\PublicAccessListener;
use App\Service\DomainInterface;
use App\Service\PublicPathMatcher;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* Unit tests for PublicAccessListener.
*
* @covers \App\Listener\PublicAccessListener
*/
final class PublicAccessListenerTest extends TestCase
{
use ListenerTestHelper;
private function makeListener(
string $publicPaths = '',
int $remainingTokens = 10,
?string $authSubdomain = null,
): PublicAccessListener {
$pathMatcher = new PublicPathMatcher($publicPaths);
$domainManager = $this->createStub(DomainInterface::class);
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
$listener = new PublicAccessListener(
$pathMatcher,
$domainManager,
$this->makeTwig(),
$this->makeRateLimiterFactory($remainingTokens),
);
$listener->setLogger(new NullLogger());
return $listener;
}
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
HttpKernelInterface::MAIN_REQUEST,
);
}
/* ── feature disabled ──────────────────────────────────────────────── */
public function testNoPublicPathsReturnsWithoutResponse(): void
{
$listener = $this->makeListener(publicPaths: '');
$request = Request::create('/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertFalse($event->hasResponse());
}
/* ── non-public path ───────────────────────────────────────────────── */
public function testNonPublicPathReturnsWithoutResponse(): void
{
$listener = $this->makeListener(publicPaths: '/public/**');
$request = Request::create('/private', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertFalse($event->hasResponse());
}
/* ── public path within rate limit ─────────────────────────────────── */
public function testPublicPathWithinRateLimitReturns200(): void
{
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
$response = $event->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('text/plain', $response->headers->get('Content-Type'));
// No Remote-User header for public access
self::assertFalse($response->headers->has('Remote-User'));
}
/* ── public path rate limited ──────────────────────────────────────── */
public function testPublicPathOverRateLimitReturns429(): void
{
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
$response = $event->getResponse();
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
self::assertSame('text/html', $response->headers->get('Content-Type'));
self::assertTrue($response->headers->has('Retry-After'));
}
public function testRateLimitedResponseContainsErrorTemplate(): void
{
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
$content = $event->getResponse()->getContent();
// Default teapot template content (env.teapot is true in test helper)
self::assertStringContainsString('teapot', $content);
}
/* ── auth subdomain is never public ────────────────────────────────── */
public function testAuthSubdomainRequestIsSkipped(): void
{
$listener = $this->makeListener(
publicPaths: '/**',
remainingTokens: 10,
authSubdomain: 'auth.example.com',
);
// Request to auth subdomain — should NOT be treated as public
$request = Request::create('https://auth.example.com/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertFalse($event->hasResponse());
}
/* ── query string is ignored ───────────────────────────────────────── */
public function testQueryStringIsIgnoredForPathMatching(): void
{
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
$request = Request::create('/public?foo=bar', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
}
/* ── domain-scoped paths ───────────────────────────────────────────── */
public function testDomainScopedPathMatchesCorrectHost(): void
{
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
$request = Request::create('https://code.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
}
public function testDomainScopedPathDoesNotMatchOtherHost(): void
{
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
$request = Request::create('https://other.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertFalse($event->hasResponse());
}
/* ── wildcard matching ─────────────────────────────────────────────── */
public function testSingleWildcardMatching(): void
{
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
}
public function testSingleWildcardDoesNotMatchDeepPath(): void
{
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
$request = Request::create('/public/a/b', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertFalse($event->hasResponse());
}
/* ── 200 response includes remaining token count ───────────────────── */
public function testOkResponseIncludesRetryAfterHeader(): void
{
// The 200 response includes a Retry-After header showing remaining tokens
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('42', $response->headers->get('Retry-After'));
}
}
+13 -6
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -13,7 +14,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class RejectListenerTest extends TestCase {
final class RejectListenerTest extends TestCase
{
use ListenerTestHelper;
private function makeListener(
@@ -29,7 +31,8 @@ final class RejectListenerTest extends TestCase {
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -37,7 +40,8 @@ final class RejectListenerTest extends TestCase {
);
}
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void {
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void
{
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
@@ -50,7 +54,8 @@ final class RejectListenerTest extends TestCase {
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void {
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void
{
$listener = $this->makeListener(teapot: false, remainingTokens: 0);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
@@ -63,7 +68,8 @@ final class RejectListenerTest extends TestCase {
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testUnblockedRequestSetsNoResponse(): void {
public function testUnblockedRequestSetsNoResponse(): void
{
$listener = $this->makeListener(remainingTokens: 5);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
@@ -74,7 +80,8 @@ final class RejectListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testBlockedResponseContainsErrorTemplateContent(): void {
public function testBlockedResponseContainsErrorTemplateContent(): void
{
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
+55 -27
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -8,20 +9,24 @@ use OutOfBoundsException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class MonitorCacheKeysTest extends TestCase {
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys {
final class MonitorCacheKeysTest extends TestCase
{
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
{
$pool ??= new ArrayAdapter();
return new MonitorCacheKeys($pool);
}
public function testConstructorInitializesEmptyPool(): void {
public function testConstructorInitializesEmptyPool(): void
{
$monitor = $this->wrap();
self::assertSame([], $monitor->getKeys());
self::assertSame([], $monitor->getChanges());
}
public function testSaveAddsKeyAndTracksChange(): void {
public function testSaveAddsKeyAndTracksChange(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('alpha');
$item->set('value');
@@ -31,7 +36,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
}
public function testSaveDeferredThenCommitAddsKey(): void {
public function testSaveDeferredThenCommitAddsKey(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('beta');
$item->set('value');
@@ -42,7 +48,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
}
public function testGetItemReturnsUnderlyingItem(): void {
public function testGetItemReturnsUnderlyingItem(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('mykey');
$item->set('data');
@@ -53,7 +60,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame('data', $fetched->get());
}
public function testGetItemsReturnsMultipleItems(): void {
public function testGetItemsReturnsMultipleItems(): void
{
$monitor = $this->wrap();
$a = $monitor->getItem('a');
$a->set(1);
@@ -70,7 +78,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['a' => 1, 'b' => 2], $keys);
}
public function testHasItemReturnsTrueForExistingKey(): void {
public function testHasItemReturnsTrueForExistingKey(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('exists');
$item->set('v');
@@ -80,7 +89,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertFalse($monitor->hasItem('missing'));
}
public function testDeleteItemRemovesKeyAndTracksRemoval(): void {
public function testDeleteItemRemovesKeyAndTracksRemoval(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('doomed');
$item->set('v');
@@ -93,7 +103,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertFalse($monitor->hasItem('doomed'));
}
public function testDeleteItemOnMissingKeyIsNoop(): void {
public function testDeleteItemOnMissingKeyIsNoop(): void
{
$monitor = $this->wrap();
$result = $monitor->deleteItem('nonexistent');
@@ -102,7 +113,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame([], $monitor->getKeys());
}
public function testDeleteItemsRemovesMultipleKeys(): void {
public function testDeleteItemsRemovesMultipleKeys(): void
{
$monitor = $this->wrap();
foreach (['x', 'y', 'z'] as $key) {
$item = $monitor->getItem($key);
@@ -118,7 +130,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
}
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void {
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void
{
$monitor = $this->wrap();
$result = $monitor->deleteItems(['ghost1', 'ghost2']);
@@ -126,7 +139,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertTrue($result);
}
public function testClearWipesPoolWhenNotEmpty(): void {
public function testClearWipesPoolWhenNotEmpty(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('keep');
$item->set('v');
@@ -138,7 +152,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame([], $monitor->getKeys());
}
public function testClearIsNoopWhenEmpty(): void {
public function testClearIsNoopWhenEmpty(): void
{
$monitor = $this->wrap();
$result = $monitor->clear();
@@ -146,7 +161,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertTrue($result);
}
public function testMarkCleanResetsChangeList(): void {
public function testMarkCleanResetsChangeList(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('temp');
$item->set('v');
@@ -160,13 +176,15 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['temp'], $monitor->getKeys());
}
public function testCommitPassesThrough(): void {
public function testCommitPassesThrough(): void
{
$monitor = $this->wrap();
self::assertTrue($monitor->commit());
}
public function testSaveKeyListThrowsOutOfBoundsException(): void {
public function testSaveKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('__key_list');
@@ -174,7 +192,8 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->save($item);
}
public function testSaveChangeListThrowsOutOfBoundsException(): void {
public function testSaveChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('__chg_list');
@@ -182,35 +201,40 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->save($item);
}
public function testDeleteKeyListThrowsOutOfBoundsException(): void {
public function testDeleteKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItem('__key_list');
}
public function testDeleteChangeListThrowsOutOfBoundsException(): void {
public function testDeleteChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItem('__chg_list');
}
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void {
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItems(['safe', '__key_list']);
}
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void {
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItems(['__chg_list']);
}
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void {
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('safe');
$item->set('value');
@@ -223,7 +247,8 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->saveDeferred($keyListItem);
}
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void {
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$changeListItem = $monitor->getItem('__chg_list');
@@ -231,7 +256,8 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->saveDeferred($changeListItem);
}
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void {
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void
{
// If the underlying pool loses its key list, getKeys should return []
$pool = new ArrayAdapter();
$monitor = new MonitorCacheKeys($pool);
@@ -249,7 +275,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame([], $monitor2->getKeys());
}
public function testDeleteItemReturnsTrueForExistingKey(): void {
public function testDeleteItemReturnsTrueForExistingKey(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('to-delete');
$item->set('value');
@@ -259,7 +286,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertNotContains('to-delete', $monitor->getKeys());
}
public function testDeleteItemsReturnsTrue(): void {
public function testDeleteItemsReturnsTrue(): void
{
$monitor = $this->wrap();
foreach (['a', 'b', 'c'] as $key) {
$item = $monitor->getItem($key);
+21 -10
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -8,8 +9,10 @@ use App\PersistCache;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class PersistCacheTest extends TestCase {
public function testBootWithEmptyStorageIsNoop(): void {
final class PersistCacheTest extends TestCase
{
public function testBootWithEmptyStorageIsNoop(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -21,7 +24,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame([], $monitor->getKeys());
}
public function testBootLoadsFromStorageIntoCache(): void {
public function testBootLoadsFromStorageIntoCache(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -43,7 +47,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame([], $cacheMonitor->getChanges());
}
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void {
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -68,7 +73,8 @@ final class PersistCacheTest extends TestCase {
self::assertNotContains('cookie_new', $monitor->getKeys());
}
public function testPersistWritesChangesToStorage(): void {
public function testPersistWritesChangesToStorage(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -89,7 +95,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
}
public function testPersistHandlesRemovals(): void {
public function testPersistHandlesRemovals(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -114,7 +121,8 @@ final class PersistCacheTest extends TestCase {
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
}
public function testPersistIsNoopWhenNoChanges(): void {
public function testPersistIsNoopWhenNoChanges(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -126,7 +134,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame([], $storageMonitor->getKeys());
}
public function testFullBootModifyPersistCycle(): void {
public function testFullBootModifyPersistCycle(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -151,7 +160,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
}
public function testPersistHandlesMixedUpdatesAndRemovals(): void {
public function testPersistHandlesMixedUpdatesAndRemovals(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -184,7 +194,8 @@ final class PersistCacheTest extends TestCase {
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
}
public function testMultipleBootModifyPersistCycles(): void {
public function testMultipleBootModifyPersistCycles(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
+39 -19
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
@@ -9,10 +10,12 @@ use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class BackupCodeManagerTest extends TestCase {
final class BackupCodeManagerTest extends TestCase
{
use TotpTestHelper;
private function makeManager(?ArrayAdapter $pool = null): BackupCodeManager {
private function makeManager(?ArrayAdapter $pool = null): BackupCodeManager
{
$pool ??= new ArrayAdapter();
$manager = new BackupCodeManager($pool);
$manager->setConfig($this->makeConfig());
@@ -20,7 +23,8 @@ final class BackupCodeManagerTest extends TestCase {
return $manager;
}
public function testGenerateReturnsRequestedCount(): void {
public function testGenerateReturnsRequestedCount(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(5);
@@ -33,7 +37,8 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testGenerateDefaultCount(): void {
public function testGenerateDefaultCount(): void
{
$manager = $this->makeManager();
$codes = $manager->generate();
@@ -41,7 +46,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertCount(10, $codes);
}
public function testGenerateZeroReturnsEmptyArray(): void {
public function testGenerateZeroReturnsEmptyArray(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(0);
@@ -49,7 +55,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertSame([], $codes);
}
public function testGeneratedCodesAreStoredInCache(): void {
public function testGeneratedCodesAreStoredInCache(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -65,7 +72,8 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testGeneratedCodesHaveFarFutureExpiry(): void {
public function testGeneratedCodesHaveFarFutureExpiry(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -77,7 +85,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
}
public function testVerifyAndConsumeValidCode(): void {
public function testVerifyAndConsumeValidCode(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(2);
@@ -86,7 +95,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue($manager->verifyAndConsume($code));
}
public function testVerifyAndConsumeMarksCodeAsUsed(): void {
public function testVerifyAndConsumeMarksCodeAsUsed(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
$codes = $manager->generate(1);
@@ -99,13 +109,15 @@ final class BackupCodeManagerTest extends TestCase {
self::assertFalse($manager->verifyAndConsume($code));
}
public function testVerifyAndConsumeInvalidCode(): void {
public function testVerifyAndConsumeInvalidCode(): void
{
$manager = $this->makeManager();
self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
}
public function testVerifyAndConsumeIsCaseInsensitive(): void {
public function testVerifyAndConsumeIsCaseInsensitive(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
$code = $codes[0];
@@ -114,7 +126,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
}
public function testVerifyAndConsumeStripsInvalidCharacters(): void {
public function testVerifyAndConsumeStripsInvalidCharacters(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
$code = $codes[0];
@@ -123,7 +136,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!'));
}
public function testExpireRemovesAllBackupCodes(): void {
public function testExpireRemovesAllBackupCodes(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
$codes = $manager->generate(5);
@@ -136,7 +150,8 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testExpireWhenNoBackupCodesIsNoop(): void {
public function testExpireWhenNoBackupCodesIsNoop(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -147,7 +162,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue(true);
}
public function testExpireRemovesOnlyBackupPrefixedKeys(): void {
public function testExpireRemovesOnlyBackupPrefixedKeys(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -169,14 +185,16 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void {
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void
{
$manager = $this->makeManager();
// empty string after preg_replace becomes 'backup_' with nothing after it
self::assertFalse($manager->verifyAndConsume(''));
}
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void {
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
$codes = $manager->generate(1);
@@ -195,7 +213,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertFalse($manager->verifyAndConsume($code));
}
public function testGenerateProducesUniqueCodes(): void {
public function testGenerateProducesUniqueCodes(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(50);
@@ -204,7 +223,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
}
public function testGenerateCodeLengthIsDigitsPlusTwo(): void {
public function testGenerateCodeLengthIsDigitsPlusTwo(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
+79 -45
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
@@ -6,44 +7,52 @@ namespace App\Tests\Unit\Service;
use App\Service\DomainManager;
use PHPUnit\Framework\TestCase;
final class DomainManagerTest extends TestCase {
private function createManager(bool $subdomainRedirect, string $authSubdomain): DomainManager {
final class DomainManagerTest extends TestCase
{
private function createManager(bool $subdomainRedirect, string $authSubdomain): DomainManager
{
return new DomainManager($subdomainRedirect, $authSubdomain);
}
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void {
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void
{
$manager = $this->createManager(false, 'auth.example.com');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void {
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void
{
$manager = $this->createManager(true, '');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseExtractsSimpleDomain(): void {
public function testAuthBaseExtractsSimpleDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertSame('example.com', $manager->authBase());
self::assertSame('auth.example.com', $manager->getAuthSubdomain());
}
public function testAuthBaseExtractsMultiPartTld(): void {
public function testAuthBaseExtractsMultiPartTld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertSame('example.co.uk', $manager->authBase());
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
}
public function testAuthBaseIsNullForLocalhostAuth(): void {
public function testAuthBaseIsNullForLocalhostAuth(): void
{
$manager = $this->createManager(true, 'localhost');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseIsNullForIpAuth(): void {
public function testAuthBaseIsNullForIpAuth(): void
{
$manager = $this->createManager(true, '192.168.1.1');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
@@ -51,90 +60,105 @@ final class DomainManagerTest extends TestCase {
/* ── validReturn ──────────────────────────────────────────────────────── */
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void {
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void
{
$manager = $this->createManager(false, '');
self::assertTrue($manager->validReturn('https://evil.com/page'));
self::assertTrue($manager->validReturn('https://example.com/ok'));
}
public function testValidReturnRejectsInvalidUrl(): void {
public function testValidReturnRejectsInvalidUrl(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('not-a-url'));
self::assertFalse($manager->validReturn(''));
}
public function testValidReturnAcceptsSameBaseDomain(): void {
public function testValidReturnAcceptsSameBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
self::assertTrue($manager->validReturn('https://example.com/'));
}
public function testValidReturnRejectsDifferentBaseDomain(): void {
public function testValidReturnRejectsDifferentBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com/phish'));
self::assertFalse($manager->validReturn('https://other-example.com/'));
}
public function testValidReturnHandlesCoUkTld(): void {
public function testValidReturnHandlesCoUkTld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
self::assertFalse($manager->validReturn('https://example.com/'));
}
public function testValidReturnRejectsUrlWithoutHost(): void {
public function testValidReturnRejectsUrlWithoutHost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('mailto:test@example.com'));
}
/* ── matchesAuth ──────────────────────────────────────────────────────── */
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void {
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void
{
$manager = $this->createManager(false, 'auth.example.com');
self::assertFalse($manager->matchesAuth('example.com'));
self::assertFalse($manager->matchesAuth('app.example.com'));
}
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void {
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void
{
$manager = $this->createManager(true, '');
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthMatchesSameBaseDomain(): void {
public function testMatchesAuthMatchesSameBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('example.com'));
self::assertTrue($manager->matchesAuth('app.example.com'));
}
public function testMatchesAuthRejectsDifferentBaseDomain(): void {
public function testMatchesAuthRejectsDifferentBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('evil.com'));
self::assertFalse($manager->matchesAuth('example.org'));
}
public function testMatchesAuthHandlesMultiPartTld(): void {
public function testMatchesAuthHandlesMultiPartTld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->matchesAuth('www.example.co.uk'));
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthRejectsIpHost(): void {
public function testMatchesAuthRejectsIpHost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('192.168.1.1'));
}
public function testMatchesAuthRejectsLocalhost(): void {
public function testMatchesAuthRejectsLocalhost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('localhost'));
}
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
public function testMatchesAuthWithDeepSubdomain(): void {
public function testMatchesAuthWithDeepSubdomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
}
public function testMatchesAuthWithTwoPartDomain(): void {
public function testMatchesAuthWithTwoPartDomain(): void
{
/* for a 2-part auth subdomain, the baseDomain retains both parts */
$manager = $this->createManager(true, 'auth.local');
self::assertSame('auth.local', $manager->authBase());
@@ -145,60 +169,65 @@ final class DomainManagerTest extends TestCase {
/* ── TLD table coverage ──────────────────────────────────────────────── */
public function testMatchesAuthWithComAuTld(): void {
// com.au is NOT in the TLD table (table has au? no, it doesn't),
// so it's treated as a standard 2-part TLD: base = com.au
public function testMatchesAuthWithComAuTld(): void
{
// com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part
$manager = $this->createManager(true, 'auth.example.com.au');
self::assertSame('com.au', $manager->authBase());
self::assertSame('example.com.au', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.au'));
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthWithCoJpTld(): void {
// co.jp is NOT in the TLD table (table has jpn under com, not jp under co)
// so base = co.jp
public function testMatchesAuthWithCoJpTld(): void
{
// co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part
$manager = $this->createManager(true, 'auth.example.co.jp');
self::assertSame('co.jp', $manager->authBase());
self::assertSame('example.co.jp', $manager->authBase());
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
}
public function testMatchesAuthWithComBrTld(): void {
// com.br: TLD table has com => [br], meaning *.br.com is multi-part
// but com.br has last=br, TLD['br'] doesn't exist, so base = com.br
public function testMatchesAuthWithComBrTld(): void
{
// com.br: TLD table has br => [com,...], so *.com.br IS multi-part
$manager = $this->createManager(true, 'auth.example.com.br');
self::assertSame('com.br', $manager->authBase());
self::assertSame('example.com.br', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.br'));
}
public function testMatchesAuthWithCoNzTld(): void {
public function testMatchesAuthWithCoNzTld(): void
{
// 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');
self::assertSame('example.co.nz', $manager->authBase());
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
}
public function testMatchesAuthWithComMxTld(): void {
public function testMatchesAuthWithComMxTld(): void
{
// 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');
self::assertSame('example.com.mx', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
}
public function testMatchesAuthWithCoInTld(): void {
public function testMatchesAuthWithCoInTld(): void
{
// co.in: in => [co,...], so *.co.in IS multi-part
$manager = $this->createManager(true, 'auth.example.co.in');
self::assertSame('example.co.in', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.co.in'));
}
public function testMatchesAuthWithBrComTld(): void {
public function testMatchesAuthWithBrComTld(): void
{
// br.com: TLD table has com => [br], so *.br.com IS multi-part
$manager = $this->createManager(true, 'auth.example.br.com');
self::assertSame('example.br.com', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.br.com'));
}
public function testSimpleTldNotTreatedAsMultiPart(): void {
public function testSimpleTldNotTreatedAsMultiPart(): void
{
// example.com is a standard 2-part domain, not multi-part
$manager = $this->createManager(true, 'auth.example.com');
self::assertSame('example.com', $manager->authBase());
@@ -208,7 +237,8 @@ final class DomainManagerTest extends TestCase {
/* ── baseDomain edge cases ───────────────────────────────────────────── */
public function testMatchesAuthWithSingleLabelHost(): void {
public function testMatchesAuthWithSingleLabelHost(): void
{
// a single-label domain (not localhost, not IP) has baseLength 1
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
// they won't match unless the auth subdomain itself is single-label
@@ -219,22 +249,26 @@ final class DomainManagerTest extends TestCase {
self::assertTrue($manager->matchesAuth('app.auth.local'));
}
public function testMatchesAuthWithEmptyStringHost(): void {
public function testMatchesAuthWithEmptyStringHost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth(''));
}
public function testValidReturnAcceptsUrlWithPort(): void {
public function testValidReturnAcceptsUrlWithPort(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
}
public function testValidReturnAcceptsUrlWithoutPath(): void {
public function testValidReturnAcceptsUrlWithoutPath(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com'));
}
public function testValidReturnRejectsDifferentDomainWithPort(): void {
public function testValidReturnRejectsDifferentDomainWithPort(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
}
+41 -20
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
@@ -18,7 +19,8 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class LoginManagerTest extends TestCase {
final class LoginManagerTest extends TestCase
{
use TotpTestHelper;
use StringTrait;
@@ -62,7 +64,8 @@ final class LoginManagerTest extends TestCase {
}
/** 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');
$nonceCache = $reflection->getValue($manager);
@@ -74,7 +77,8 @@ final class LoginManagerTest extends TestCase {
return $nonce;
}
public function testCheckTokenReturnsNullForInvalidTotp(): void {
public function testCheckTokenReturnsNullForInvalidTotp(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
@@ -85,7 +89,8 @@ final class LoginManagerTest extends TestCase {
self::assertNull($manager->checkToken($payload, $request));
}
public function testCheckTokenReturnsNullForSpentNonce(): void {
public function testCheckTokenReturnsNullForSpentNonce(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager);
@@ -103,7 +108,8 @@ final class LoginManagerTest extends TestCase {
self::assertNull($manager->checkToken($payload, $request));
}
public function testCheckTokenReturnsNullForMissingNonce(): void {
public function testCheckTokenReturnsNullForMissingNonce(): void
{
$manager = $this->makeLoginManager();
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
@@ -120,7 +126,8 @@ final class LoginManagerTest extends TestCase {
self::assertNull($manager->checkToken($payload, $request));
}
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void {
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -136,7 +143,8 @@ final class LoginManagerTest extends TestCase {
self::assertTrue($response->headers->has('Set-Cookie'));
}
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void {
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
@@ -154,7 +162,8 @@ final class LoginManagerTest extends TestCase {
self::assertFalse($response->headers->has('Location'));
}
public function testSuccessfulLoginSetsRemoteUserHeader(): void {
public function testSuccessfulLoginSetsRemoteUserHeader(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
@@ -168,7 +177,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testSuccessfulLoginJsonResponse(): void {
public function testSuccessfulLoginJsonResponse(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
$payload->json = true;
@@ -185,7 +195,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginHtmlResponse(): void {
public function testSuccessfulLoginHtmlResponse(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
$payload->json = false;
@@ -200,7 +211,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testSuccessfulLoginWithReturnUrl(): void {
public function testSuccessfulLoginWithReturnUrl(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -214,7 +226,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void {
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -229,7 +242,8 @@ final class LoginManagerTest extends TestCase {
self::assertStringStartsWith('/login', $location);
}
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void {
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void
{
$manager = $this->makeLoginManager(ipTtl: 0);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -244,7 +258,8 @@ final class LoginManagerTest extends TestCase {
self::assertTrue($response->headers->has('Set-Cookie'));
}
public function testIpScopeWhenEnabledSetsIpSession(): void {
public function testIpScopeWhenEnabledSetsIpSession(): void
{
$manager = $this->makeLoginManager(ipTtl: 1800);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -264,7 +279,8 @@ final class LoginManagerTest extends TestCase {
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
}
public function testBackupCodeAuthentication(): void {
public function testBackupCodeAuthentication(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
@@ -278,7 +294,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame(303, $response->getStatusCode());
}
public function testNonceIsConsumedAfterSuccessfulLogin(): void {
public function testNonceIsConsumedAfterSuccessfulLogin(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager);
@@ -296,7 +313,8 @@ final class LoginManagerTest extends TestCase {
self::assertFalse($nonceItem->get());
}
public function testUlidCollisionThrowsHttpException(): void {
public function testUlidCollisionThrowsHttpException(): void
{
// Use a stub pool where every cookie_ key is already a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
@@ -358,7 +376,8 @@ final class LoginManagerTest extends TestCase {
$manager->checkToken($payload, $request);
}
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void {
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void
{
$manager = $this->makeLoginManager(
subdomainRedirect: true,
authSubdomain: 'auth.example.com',
@@ -381,7 +400,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void {
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void
{
$manager = $this->makeLoginManager(
subdomainRedirect: true,
authSubdomain: 'auth.example.com',
@@ -404,7 +424,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void {
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PublicPathMatcher;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for PublicPathMatcher — path pattern parsing and matching.
*
* @covers \App\Service\PublicPathMatcher
*/
final class PublicPathMatcherTest extends TestCase
{
/* ── empty / disabled ──────────────────────────────────────────────── */
public function testEmptyStringResultsInNoPatterns(): void
{
$matcher = new PublicPathMatcher('');
self::assertTrue($matcher->isEmpty());
self::assertFalse($matcher->matches('example.com', '/public'));
}
public function testWhitespaceOnlyStringResultsInNoPatterns(): void
{
$matcher = new PublicPathMatcher(' ');
self::assertTrue($matcher->isEmpty());
}
/* ── exact path matching ───────────────────────────────────────────── */
public function testExactPathMatch(): void
{
$matcher = new PublicPathMatcher('/public');
self::assertTrue($matcher->matches('example.com', '/public'));
}
public function testExactPathDoesNotMatchSubpath(): void
{
$matcher = new PublicPathMatcher('/public');
self::assertFalse($matcher->matches('example.com', '/public/'));
self::assertFalse($matcher->matches('example.com', '/public/repo'));
}
public function testExactPathDoesNotMatchDifferentPath(): void
{
$matcher = new PublicPathMatcher('/public');
self::assertFalse($matcher->matches('example.com', '/private'));
self::assertFalse($matcher->matches('example.com', '/'));
}
/* ── single wildcard * ─────────────────────────────────────────────── */
public function testSingleWildcardMatchesOneSegment(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertTrue($matcher->matches('example.com', '/public/repo'));
self::assertTrue($matcher->matches('example.com', '/public/xyz'));
}
public function testSingleWildcardDoesNotMatchBasePath(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public'));
}
public function testSingleWildcardDoesNotCrossSegments(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public/a/b'));
}
public function testSingleWildcardDoesNotMatchEmptySegment(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public/'));
}
/* ── double wildcard ** ────────────────────────────────────────────── */
public function testDoubleWildcardMatchesMultipleSegments(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('example.com', '/public/a'));
self::assertTrue($matcher->matches('example.com', '/public/a/b/c'));
}
public function testDoubleWildcardDoesNotMatchBasePath(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertFalse($matcher->matches('example.com', '/public'));
}
public function testDoubleWildcardMatchesTrailingSlash(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('example.com', '/public/'));
}
/* ── mid-path wildcards ────────────────────────────────────────────── */
public function testMidPathSingleWildcard(): void
{
$matcher = new PublicPathMatcher('/api/*/status');
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
self::assertTrue($matcher->matches('example.com', '/api/v2/status'));
self::assertFalse($matcher->matches('example.com', '/api/v1/v2/status'));
self::assertFalse($matcher->matches('example.com', '/api/status'));
}
public function testMidPathDoubleWildcard(): void
{
$matcher = new PublicPathMatcher('/api/**/status');
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
self::assertTrue($matcher->matches('example.com', '/api/v1/v2/status'));
self::assertTrue($matcher->matches('example.com', '/api/status'));
}
/* ── multiple patterns ─────────────────────────────────────────────── */
public function testMultiplePatternsCommaSeparated(): void
{
$matcher = new PublicPathMatcher('/public/**,/api/status,/health');
self::assertTrue($matcher->matches('example.com', '/public/repo'));
self::assertTrue($matcher->matches('example.com', '/api/status'));
self::assertTrue($matcher->matches('example.com', '/health'));
self::assertFalse($matcher->matches('example.com', '/private'));
}
public function testMultiplePatternsWithWhitespace(): void
{
$matcher = new PublicPathMatcher('/public/**, /api/status, /health');
self::assertTrue($matcher->matches('example.com', '/public/repo'));
self::assertTrue($matcher->matches('example.com', '/api/status'));
self::assertTrue($matcher->matches('example.com', '/health'));
}
public function testEmptySegmentsInCommaListAreIgnored(): void
{
$matcher = new PublicPathMatcher('/public,,/health,');
self::assertFalse($matcher->isEmpty());
self::assertTrue($matcher->matches('example.com', '/public'));
self::assertTrue($matcher->matches('example.com', '/health'));
}
/* ── domain-prefixed patterns ──────────────────────────────────────── */
public function testDomainPrefixedPatternMatchesOnThatHost(): void
{
$matcher = new PublicPathMatcher('code.example.com/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
}
public function testDomainPrefixedPatternDoesNotMatchOtherHost(): void
{
$matcher = new PublicPathMatcher('code.example.com/public/**');
self::assertFalse($matcher->matches('other.example.com', '/public/repo'));
self::assertFalse($matcher->matches('example.com', '/public/repo'));
}
public function testPathWithoutDomainPrefixMatchesAnyHost(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
self::assertTrue($matcher->matches('other.example.com', '/public/repo'));
self::assertTrue($matcher->matches('localhost', '/public/repo'));
}
public function testMixedDomainPrefixedAndPlainPatterns(): void
{
$matcher = new PublicPathMatcher('/health,code.example.com/public/**');
self::assertTrue($matcher->matches('any.host', '/health'));
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
self::assertFalse($matcher->matches('other.host', '/public/repo'));
}
public function testDomainPrefixedRootPathMatchesRoot(): void
{
// host/ — the trailing slash is the entire path, nothing after it
$matcher = new PublicPathMatcher('code.example.com/');
self::assertTrue($matcher->matches('code.example.com', '/'));
self::assertFalse($matcher->matches('code.example.com', '/public'));
self::assertFalse($matcher->matches('other.example.com', '/'));
}
public function testDomainPrefixedRootWithOtherPatterns(): void
{
// The exact scenario from the bug report
$matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**');
self::assertTrue($matcher->matches('code.example.com', '/'));
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
self::assertFalse($matcher->matches('code.example.com', '/private'));
self::assertFalse($matcher->matches('other.example.com', '/'));
}
public function testDomainPrefixIsCaseInsensitive(): void
{
$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'));
}
/* ── invalid patterns ──────────────────────────────────────────────── */
public function testPatternWithoutLeadingSlashIsIgnored(): void
{
$matcher = new PublicPathMatcher('public');
self::assertTrue($matcher->isEmpty());
}
public function testInvalidPatternAmongValidOnesIsIgnored(): void
{
$matcher = new PublicPathMatcher('invalid,/public');
self::assertFalse($matcher->isEmpty());
self::assertTrue($matcher->matches('example.com', '/public'));
}
/* ── special regex characters in paths ─────────────────────────────── */
public function testSpecialRegexCharactersAreEscaped(): void
{
$matcher = new PublicPathMatcher('/path.with.dots');
self::assertTrue($matcher->matches('example.com', '/path.with.dots'));
self::assertFalse($matcher->matches('example.com', '/pathXwithXdots'));
}
public function testPlusCharacterIsLiteral(): void
{
$matcher = new PublicPathMatcher('/a+b');
self::assertTrue($matcher->matches('example.com', '/a+b'));
self::assertFalse($matcher->matches('example.com', '/aaab'));
}
/* ── root path ─────────────────────────────────────────────────────── */
public function testRootPathMatch(): void
{
$matcher = new PublicPathMatcher('/');
self::assertTrue($matcher->matches('example.com', '/'));
self::assertFalse($matcher->matches('example.com', '/anything'));
}
public function testWildcardAtRoot(): void
{
$matcher = new PublicPathMatcher('/*');
self::assertTrue($matcher->matches('example.com', '/anything'));
self::assertFalse($matcher->matches('example.com', '/a/b'));
self::assertFalse($matcher->matches('example.com', '/'));
}
public function testDoubleWildcardAtRoot(): void
{
$matcher = new PublicPathMatcher('/**');
self::assertTrue($matcher->matches('example.com', '/'));
self::assertTrue($matcher->matches('example.com', '/anything'));
self::assertTrue($matcher->matches('example.com', '/a/b/c'));
}
}
+9 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -6,18 +7,22 @@ namespace App\Tests\Unit\Trait;
use App\Trait\CookieNameTrait;
use PHPUnit\Framework\TestCase;
final class CookieNameTraitTest extends TestCase {
final class CookieNameTraitTest extends TestCase
{
use CookieNameTrait;
public function testCookieName(): void {
public function testCookieName(): void
{
self::assertSame('__Host-Http-Preauth', $this->cookieName());
}
public function testAuthCookieName(): void {
public function testAuthCookieName(): void
{
self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
}
public function testHeaderName(): void {
public function testHeaderName(): void
{
self::assertSame('X-Preauth', $this->headerName());
}
}
+43 -16
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -10,20 +11,24 @@ use OTPHP\TOTPInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class GetTotpTraitTest extends TestCase {
final class GetTotpTraitTest extends TestCase
{
use TotpTestHelper;
private function makeObject(): object {
return new class {
private function makeObject(): object
{
return new class () {
use GetTotpTrait;
public function publicGetTotp(): TOTPInterface {
public function publicGetTotp(): TOTPInterface
{
return $this->getTotp();
}
};
}
public function testSetConfigSetsProperty(): void {
public function testSetConfigSetsProperty(): void
{
$obj = $this->makeObject();
$config = $this->makeConfig();
@@ -33,7 +38,8 @@ final class GetTotpTraitTest extends TestCase {
self::assertSame($config, $reflection->getValue($obj));
}
public function testGetTotpReturnsTotpInterface(): void {
public function testGetTotpReturnsTotpInterface(): void
{
$obj = $this->makeObject();
$obj->setConfig($this->makeConfig());
@@ -42,7 +48,8 @@ final class GetTotpTraitTest extends TestCase {
self::assertInstanceOf(TOTPInterface::class, $totp);
}
public function testGetTotpReturnsValidCode(): void {
public function testGetTotpReturnsValidCode(): void
{
$obj = $this->makeObject();
$obj->setConfig($this->makeConfig());
@@ -52,14 +59,24 @@ final class GetTotpTraitTest extends TestCase {
self::assertSame($this->validTotpCode(), $totp->now());
}
public function testGetTotpThrowsOnInvalidUri(): void {
public function testGetTotpThrowsOnInvalidUri(): void
{
$obj = $this->makeObject();
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
$config = new ConfigBag(
$utilities, $clock,
3600, 'not-a-valid-uri', 0, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'not-a-valid-uri',
0,
false,
'Error',
'Teapot',
'Too Many',
'session',
'authenticated',
'',
);
$obj->setConfig($config);
@@ -70,21 +87,31 @@ final class GetTotpTraitTest extends TestCase {
$obj->publicGetTotp();
}
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void {
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void
{
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
// so the instanceof check in getTotp() should throw an HttpException(500)
$obj = $this->makeObject();
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0', 0, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0',
0,
false,
'Error',
'Teapot',
'Too Many',
'session',
'authenticated',
'',
);
$obj->setConfig($config);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Internal Server Exception');
$this->expectExceptionMessage('Internal Server Error');
$obj->publicGetTotp();
}
}
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -7,10 +8,12 @@ use App\Trait\HasLoggerTrait;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
final class HasLoggerTraitTest extends TestCase {
final class HasLoggerTraitTest extends TestCase
{
use HasLoggerTrait;
public function testSetLogger(): void {
public function testSetLogger(): void
{
$logger = $this->createStub(LoggerInterface::class);
$this->setLogger($logger);
self::assertSame($logger, $this->logger);
+89 -31
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -15,22 +16,27 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* Wraps the trait in a concrete class with public proxies so the protected
* methods can be exercised from test scope.
*/
final class MakeNonceTraitTest extends TestCase {
private function makeObject(): object {
return new class {
final class MakeNonceTraitTest extends TestCase
{
private function makeObject(): object
{
return new class () {
use MakeNonceTrait;
public function publicMakeNonce(int $retries = 3): string {
public function publicMakeNonce(int $retries = 3): string
{
return $this->makeNonce($retries);
}
public function publicMakeCacheKey(string $name): string {
public function publicMakeCacheKey(string $name): string
{
return $this->makeCacheKey($name);
}
};
}
public function testMakeNonceReturnsBase64UrlString(): void {
public function testMakeNonceReturnsBase64UrlString(): void
{
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache(new ArrayAdapter());
@@ -44,7 +50,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
}
public function testMakeNonceStoresNonceInCache(): void {
public function testMakeNonceStoresNonceInCache(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
@@ -59,7 +66,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertTrue($item->get());
}
public function testMakeNonceSetsExpiry(): void {
public function testMakeNonceSetsExpiry(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
@@ -74,7 +82,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertGreaterThan(time(), (int) $expiry);
}
public function testTwoNoncesAreDifferent(): void {
public function testTwoNoncesAreDifferent(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
@@ -86,7 +95,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertNotSame($nonce1, $nonce2);
}
public function testMakeNonceThrowsAfterMaxRetries(): void {
public function testMakeNonceThrowsAfterMaxRetries(): void
{
// Create a stub pool that always reports every key as a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
@@ -105,46 +115,93 @@ final class MakeNonceTraitTest extends TestCase {
$obj->publicMakeNonce();
}
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void {
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void
{
// Use a spy pool that returns isHit=true on the first getItem call
// (simulating a collision), then delegates to a real ArrayAdapter for
// subsequent calls so the retry succeeds.
$realPool = new ArrayAdapter();
$collisionCount = 0;
$spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
$spyPool = new class ($realPool, $collisionCount) implements CacheItemPoolInterface {
private int $hits = 0;
public function __construct(
private CacheItemPoolInterface $inner,
private int &$hitCounter,
) {}
) {
}
public function getItem(string $key): CacheItemInterface {
public function getItem(string $key): CacheItemInterface
{
$item = $this->inner->getItem($key);
// pretend the first requested key is already a hit (collision)
if ($this->hits === 0) {
$this->hits++;
$this->hitCounter++;
return new class($key) implements CacheItemInterface {
public function __construct(private string $key) {}
public function getKey(): string { return $this->key; }
public function get(): mixed { return true; }
public function isHit(): bool { return true; }
public function set(mixed $value): static { return $this; }
public function expiresAt(?\DateTimeInterface $expiration): static { return $this; }
public function expiresAfter(int|\DateInterval|null $time): static { return $this; }
return new class ($key) implements CacheItemInterface {
public function __construct(private string $key)
{
}
public function getKey(): string
{
return $this->key;
}
public function get(): mixed
{
return true;
}
public function isHit(): bool
{
return true;
}
public function set(mixed $value): static
{
return $this;
}
public function expiresAt(?\DateTimeInterface $expiration): static
{
return $this;
}
public function expiresAfter(int|\DateInterval|null $time): static
{
return $this;
}
};
}
return $item;
}
public function getItems(array $keys = []): iterable { return $this->inner->getItems($keys); }
public function hasItem(string $key): bool { return $this->inner->hasItem($key); }
public function clear(): bool { return $this->inner->clear(); }
public function deleteItem(string $key): bool { return $this->inner->deleteItem($key); }
public function deleteItems(array $keys): bool { return $this->inner->deleteItems($keys); }
public function save(CacheItemInterface $item): bool { return $this->inner->save($item); }
public function saveDeferred(CacheItemInterface $item): bool { return $this->inner->saveDeferred($item); }
public function commit(): bool { return $this->inner->commit(); }
public function getItems(array $keys = []): iterable
{
return $this->inner->getItems($keys);
}
public function hasItem(string $key): bool
{
return $this->inner->hasItem($key);
}
public function clear(): bool
{
return $this->inner->clear();
}
public function deleteItem(string $key): bool
{
return $this->inner->deleteItem($key);
}
public function deleteItems(array $keys): bool
{
return $this->inner->deleteItems($keys);
}
public function save(CacheItemInterface $item): bool
{
return $this->inner->save($item);
}
public function saveDeferred(CacheItemInterface $item): bool
{
return $this->inner->saveDeferred($item);
}
public function commit(): bool
{
return $this->inner->commit();
}
};
$obj = $this->makeObject();
@@ -158,7 +215,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
}
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void {
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void
{
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
+67 -9
View File
@@ -1,35 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
use App\Tests\Support\TotpTestHelper;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase;
final class StringTraitTest extends TestCase {
final class StringTraitTest extends TestCase
{
use StringTrait;
use TotpTestHelper;
public function testMakeCacheKeySanitizesInvalidChars(): void {
public function testMakeCacheKeySanitizesInvalidChars(): void
{
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'));
}
public function testMakeCacheKeyPreservesValidChars(): void {
public function testMakeCacheKeyPreservesValidChars(): void
{
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
}
public function testMakeCacheKeyTruncatesLongNames(): void {
public function testMakeCacheKeyTruncatesLongNames(): void
{
$long = str_repeat('a', 300);
$result = $this->makeCacheKey($long);
self::assertSame(128, mb_strlen($result));
}
public function testMakeCacheKeyEmptyString(): void {
public function testMakeCacheKeyEmptyString(): void
{
self::assertSame('', $this->makeCacheKey(''));
}
public function testMakeCacheKeyWithOnlyInvalidChars(): void {
public function testMakeCacheKeyWithOnlyInvalidChars(): void
{
// preg_replace with + collapses consecutive invalid chars into one _
self::assertSame('_', $this->makeCacheKey('!!!'));
self::assertSame('_', $this->makeCacheKey(' '));
@@ -37,7 +46,8 @@ final class StringTraitTest extends TestCase {
self::assertSame('_', $this->makeCacheKey('!@ #'));
}
public function testMakeCacheKeyTruncatesToExactly128(): void {
public function testMakeCacheKeyTruncatesToExactly128(): void
{
$input = str_repeat('a', 128);
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
self::assertSame($input, $this->makeCacheKey($input));
@@ -46,15 +56,63 @@ final class StringTraitTest extends TestCase {
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
}
public function testMakeCacheKeyWithMultibyteChars(): void {
public function testMakeCacheKeyWithMultibyteChars(): void
{
// multibyte chars are replaced with a single underscore
$result = $this->makeCacheKey('héllo wörld');
// é and ö are not in [A-Za-z0-9_.] so they become _
self::assertSame('h_llo_w_rld', $result);
}
public function testMakeCacheKeyWithEmoji(): void {
public function testMakeCacheKeyWithEmoji(): void
{
$result = $this->makeCacheKey('a🎉b');
self::assertSame('a_b', $result);
}
/* ── authSuccessResponse ──────────────────────────────────────────── */
public function testAuthSuccessResponseSessionMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'session');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('hi alice', $response->getContent());
self::assertSame('text/plain', $response->headers->get('Content-Type'));
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseStaticMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('hi alice', $response->getContent());
self::assertSame('authenticated', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseMappedMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('admin', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseMappedModeFallback(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('unknown', $config);
self::assertSame('unknown', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseNoneModeOmitsHeader(): void
{
$config = $this->makeConfig(remoteUserMode: 'none');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('hi alice', $response->getContent());
self::assertFalse($response->headers->has('Remote-User'));
}
}
+13 -6
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -8,14 +9,17 @@ use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class UtilitiesTest extends TestCase {
private function makeUtilities(?ArrayAdapter $pool = null, ?ClockInterface $clock = null): Utilities {
final class UtilitiesTest extends TestCase
{
private function makeUtilities(?ArrayAdapter $pool = null, ?ClockInterface $clock = null): Utilities
{
$pool ??= new ArrayAdapter();
$clock ??= $this->createStub(ClockInterface::class);
return new Utilities($clock, $pool);
}
public function testLoadTotpReturnsCachedValueWhenPresent(): void {
public function testLoadTotpReturnsCachedValueWhenPresent(): void
{
$pool = new ArrayAdapter();
$item = $pool->getItem('totp');
$item->set('otpauth://totp/cached?secret=ABCDEFGH');
@@ -28,7 +32,8 @@ final class UtilitiesTest extends TestCase {
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
}
public function testLoadTotpGeneratesAndStoresWhenMissing(): void {
public function testLoadTotpGeneratesAndStoresWhenMissing(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
@@ -43,7 +48,8 @@ final class UtilitiesTest extends TestCase {
self::assertSame($result, $cached->get());
}
public function testLoadTotpSetsFarFutureExpiry(): void {
public function testLoadTotpSetsFarFutureExpiry(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
@@ -55,7 +61,8 @@ final class UtilitiesTest extends TestCase {
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
}
public function testLoadTotpIsIdempotentAfterGeneration(): void {
public function testLoadTotpIsIdempotentAfterGeneration(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);