4 Commits
Author SHA1 Message Date
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
62 changed files with 2925 additions and 1331 deletions
+12
View File
@@ -0,0 +1,12 @@
.git/
.gitignore
var/
vendor/
tests/
.phpunit.cache/
docs/
*.md
.env
.env.test
.env.local
composer.phar
+3
View File
@@ -29,5 +29,8 @@ jobs:
- 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
+9
View File
@@ -12,3 +12,12 @@
/.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 ###
+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)
;
+499
View File
@@ -0,0 +1,499 @@
# 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. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
exceeded login attempt threshold → `418 I'm a Teapot` (or `429`).
4. **LoginListener** (priority 66) — Detects login attempts via
`X-Preauth` header (base64url JSON) or POST form on auth subdomain.
Validates TOTP/backup codes through `LoginManager`.
5. **InterceptListener** (priority 55) — Fallback: if no listener has
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 10-second leeway** — 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** | 222 |
| **Assertions** | 469 |
| **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/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/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 |
### 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 ✦
**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.
**Design:**
- New config variables:
- `PUBLIC_MODE=false` — Enable public access for specific services
- `PUBLIC_RATE_LIMIT=10` — Max requests per minute from a single IP
on public paths
- `PUBLIC_RATE_WINDOW=60` — Sliding window in seconds
- `PUBLIC_BURST=20` — Allow short bursts above the sustained rate
- New listener: **PublicListener** (priority 95, between AcceptListener
and AllowListener):
- Checks if the request matches a public path pattern (configured per
service via Caddy's `forward_auth` URI or a header like
`X-Preauth-Public: true`).
- If public mode is enabled for this request, applies aggressive
per-IP rate limiting (separate from the login rate limiter).
- If within rate limit → `200 OK` (no `Remote-User` header, or a
`Remote-User: public` marker).
- If over rate limit → `429 Too Many Requests` with `Retry-After`
header.
- Caddy config would use different `forward_auth` snippets for public
vs. protected services:
```caddyfile
# Protected service — requires TOTP
bitwarden.example.com {
forward_auth preauth { copy_headers Remote-User }
reverse_proxy bitwarden:80
}
# Public but rate-limited service
git.example.com {
forward_auth preauth/public { copy_headers Remote-User }
reverse_proxy gitea:3000
}
```
- Consider integration with Caddy's own rate limiting as a second layer
of defense (rate limit at the reverse proxy before traffic even hits
preauth).
- [ ] Design public path detection mechanism (URI-based or header-based)
- [ ] Implement `PublicListener` with separate rate limiter pool
- [ ] Add config variables and defaults
- [ ] Update Caddyfile example with public service snippet
- [ ] Tests for public mode (within limit, over limit, burst behavior)
- [ ] Documentation in README
### 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 (currently inline in README — formalise it)
- GitHub Actions CI (run tests on push/PR, build Docker image on tag)
- [ ] **Security review:**
- Consider CSRF protection on the POST form login (auth subdomain)
- Consider adding `X-Content-Type-Options: nosniff` and other security
headers to responses
- 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.8.1) | Production | Current stable release |
| `kat-tests` | ✅ Ready to merge | 222 tests, 100% coverage, all passing |
| `origin/improved-rate-limiting` | Stale | Compound sliding-window rate limiting. Already merged into main via develop. |
| `origin/cache-persistence-improvement` | Merged (0.7.0) | Only persist changed keys. In main. |
| `origin/remove-static-secret` | Merged | Removed static password, replaced with backup codes. In main. |
| `origin/cleanup-cline*`, `cline-wip` | Experimental | Code cleanup attempts, not merged. |
| `origin/add-notes` | Minor | Documentation additions. |
---
## 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. ✨*
+4 -2
View File
@@ -1,7 +1,9 @@
#!/bin/sh
# Dev utility — builds and runs the preauth container locally.
# Not for production use.
docker container rm preauth
docker build . -t digtialadapt/preauth:dev
docker build . -t digitaladapt/preauth:dev
docker run --name preauth \
-e APP_ENV=dev \
-e APP_DEBUG=true \
@@ -10,4 +12,4 @@ docker run --name preauth \
-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
-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;
}
}
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
use App\Kernel;
+21 -1
View File
@@ -7,6 +7,26 @@ So, I built a simple authentication gateway, which eventually turned into this p
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
## 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
```
## Requirements
* Docker
@@ -15,7 +35,7 @@ It sits between your reverse proxy and web service to add extra protection, whil
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
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.
There is an example Caddyfile in /docs/ and an example.env file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
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.
+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();
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Command;
@@ -13,7 +14,8 @@ use Symfony\Component\Console\Output\OutputInterface;
/** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */
final class GenerateBackupCodesCommand extends Command {
final class GenerateBackupCodesCommand extends Command
{
public function __construct(
private readonly BackupCodeInterface $manager,
private readonly PersistCache $persistCache,
@@ -21,14 +23,16 @@ final class GenerateBackupCodesCommand extends Command {
parent::__construct();
}
protected function configure(): void {
protected function configure(): void
{
$this->setName('app:generate-backup-codes');
$this->setDescription('Generate singleuse 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');
+19 -9
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -7,7 +8,8 @@ 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;
@@ -39,35 +41,43 @@ final readonly class ConfigBag {
$this->tooManyTitle = $tooManyTitle;
}
public function clock(): ClockInterface {
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;
}
}
+16 -9
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Data;
@@ -7,18 +8,20 @@ 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 +32,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 +46,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 ||
@@ -63,11 +68,13 @@ final class Payload {
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;
+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';
+7 -3
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,7 +26,8 @@ class Kernel extends BaseKernel {
}
/** @throws InvalidArgumentException */
public function terminate(Request $request, Response $response): void {
public function terminate(Request $request, Response $response): void
{
$this->persistCache->persist();
parent::terminate($request, $response);
+8 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -13,7 +14,8 @@ 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,13 +23,15 @@ final readonly class AcceptListener {
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
) {}
) {
}
/** @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();
$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");
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -12,18 +13,21 @@ 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 {
public function onKernelRequest(RequestEvent $event): void
{
if ($this->config->ipTtl() > 0) {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
if ($this->sessionCache->hasItem($ipKey)) {
+16 -7
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 {
@@ -52,13 +58,16 @@ final readonly class InterceptListener {
$hasCookie = (bool) $event->getRequest()->cookies->get(
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
);
$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(
+19 -11
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -23,7 +24,8 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class LoginListener {
final readonly class LoginListener
{
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
@@ -32,18 +34,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 +54,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 +79,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 ? $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;
+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']
));
+41 -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,49 @@ 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 {
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 {
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 +97,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return true;
}
public function deleteItem(string $key): bool {
public function deleteItem(string $key): bool
{
$this->isValid($key);
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -101,7 +113,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return $this->cache->deleteItem($key);
}
public function deleteItems(array $keys): bool {
public function deleteItems(array $keys): bool
{
$this->allValid($keys);
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -119,23 +132,27 @@ 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 {
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 +164,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 +174,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 +186,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) {
+2 -2
View File
@@ -2,13 +2,13 @@
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
+18 -9
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -14,19 +15,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 +37,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 +53,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,7 +70,8 @@ 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));
@@ -77,7 +83,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',
'2999-12-31'
));
$this->sessionCache->save($backupItem);
@@ -87,7 +94,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 +103,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',
'2999-12-31'
));
$this->sessionCache->saveDeferred($backupItem);
}
+2 -1
View File
@@ -2,7 +2,8 @@
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;
+17 -9
View File
@@ -1,11 +1,13 @@
<?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'],
@@ -38,7 +40,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 +51,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)) {
@@ -70,7 +74,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 +84,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,7 +97,8 @@ 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;
@@ -106,12 +113,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);
}
+2 -1
View File
@@ -7,7 +7,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;
}
+11 -6
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,7 +38,8 @@ 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 */
@@ -69,7 +72,7 @@ final readonly class LoginManager implements LoginInterface {
/* 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 +107,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(
@@ -136,7 +140,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");
+9 -4
View File
@@ -1,22 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait CookieNameTrait {
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;
}
}
+9 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -9,17 +10,21 @@ use OTPHP\TOTPInterface;
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;
+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
+5 -2
View File
@@ -1,13 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait StringTrait {
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 {
public function makeCacheKey(string $name): string
{
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
}
}
+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',
'2999-12-31'
));
$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": {
+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');
+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;
}
};
+28 -12
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
@@ -17,7 +18,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 +27,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();
}
@@ -67,9 +76,15 @@ trait TotpTestHelper {
$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,
);
}
@@ -77,7 +92,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);
+2 -1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests;
@@ -27,7 +28,7 @@ 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) {
+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,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Command;
@@ -10,25 +11,29 @@ 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 +47,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 +64,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 +83,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,9 +101,11 @@ final class GenerateBackupCodesCommandTest extends TestCase {
self::assertSame(0, $exit);
}
public function testZeroCodesOutputsNothing(): void {
public function testZeroCodesOutputsNothing(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]), $this->makePersistCache()
$this->makeManagerStub([]),
$this->makePersistCache()
);
$command->setName('app:generate-backup-codes');
@@ -105,9 +116,11 @@ final class GenerateBackupCodesCommandTest extends TestCase {
self::assertSame('', trim($tester->getDisplay()));
}
public function testCommandNameAndDescriptionAreConfigured(): void {
public function testCommandNameAndDescriptionAreConfigured(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]), $this->makePersistCache()
$this->makeManagerStub([]),
$this->makePersistCache()
);
// configuring via the Application runs the protected configure()
$app = new \Symfony\Component\Console\Application();
+49 -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,21 @@ 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!'
);
self::assertSame($clock, $config->clock());
@@ -48,43 +58,63 @@ 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'
);
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'
);
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'
);
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(''));
}
+17 -8
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -14,19 +15,22 @@ 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 {
private function makeListener(ArrayAdapter $pool, DomainManager $domainManager): AcceptListener
{
$listener = new AcceptListener($pool, $domainManager);
$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 +40,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 +64,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 +87,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 +99,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 +114,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);
+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');
+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);
+73 -36
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,7 +169,8 @@ final class DomainManagerTest extends TestCase {
/* ── TLD table coverage ──────────────────────────────────────────────── */
public function testMatchesAuthWithComAuTld(): void {
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
$manager = $this->createManager(true, 'auth.example.com.au');
@@ -154,7 +179,8 @@ final class DomainManagerTest extends TestCase {
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthWithCoJpTld(): void {
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
$manager = $this->createManager(true, 'auth.example.co.jp');
@@ -162,7 +188,8 @@ final class DomainManagerTest extends TestCase {
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
}
public function testMatchesAuthWithComBrTld(): void {
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
$manager = $this->createManager(true, 'auth.example.com.br');
@@ -170,35 +197,40 @@ final class DomainManagerTest extends TestCase {
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 +240,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 +252,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);
+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());
}
}
+36 -15
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,21 @@ 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'
);
$obj->setConfig($config);
@@ -70,16 +84,23 @@ 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'
);
$obj->setConfig($config);
+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);
+19 -9
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -6,30 +7,36 @@ namespace App\Tests\Unit\Trait;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase;
final class StringTraitTest extends TestCase {
final class StringTraitTest extends TestCase
{
use StringTrait;
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 +44,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,14 +54,16 @@ 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);
}
+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);