Add PublicAccessListener (priority 84) that allows rate-limited unauthenticated access to configured public paths. Authenticated users bypass this listener entirely via AcceptListener/AllowListener. New components: - PublicPathMatcher service with wildcard path matching (* and **) and optional host-prefix scoping - PublicAccessListener applying per-IP rate limiting to public paths - Separate public_limiter compound rate limiter (burst + sustained) - publicRateLimitCache pool (APCu in prod, array in tests) New env vars: - PUBLIC_PATHS (comma-separated path patterns, empty = disabled) - PUBLIC_BURST_COUNT/PUBLIC_BURST_TIME (default 100/60s) - PUBLIC_UPPER_COUNT/PUBLIC_UPPER_TIME (default 500/3600s) Tests: 52 new tests (29 unit for PublicPathMatcher, 12 unit for PublicAccessListener, 11 functional for PublicAccessFlowTest). Total: 293 tests, 605 assertions, all passing. PHP CS Fixer: 0 of 63 files need fixing. Documentation: README, CHANGELOG, ROADMAP, Caddyfile, example.env all updated with public access configuration and examples.
22 KiB
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
- AcceptListener (priority 99) — Checks for valid session cookie.
If found →
200 OK+Remote-Userheader → Caddy proxies to backend. - AllowListener (priority 88) — If
IP_TTLis enabled, checks for valid IP-based session. If found →200 OK+Remote-User. - PublicAccessListener (priority 84) — If
PUBLIC_PATHSis configured and the request matches a public path pattern, applies per-IP rate limiting. Within limit →200 OK. Over limit →429. Authenticated users never reach this listener. - RejectListener (priority 77) — Rate-limiting gate. If IP has
exceeded login attempt threshold →
418 I'm a Teapot(or429). - LoginListener (priority 66) — Detects login attempts via
X-Preauthheader (base64url JSON) or POST form on auth subdomain. Validates TOTP/backup codes throughLoginManager. - 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.
MonitorCacheKeyswraps 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-Preauthcookie name (domain-scoped, no__Host-prefix).- Nonce system — 15-byte random nonces, single-use, 120s TTL, with retry-on-collision (up to 3 attempts).
- TOTP with ±1 period leeway (±30 seconds) — Accommodates clock drift.
- Backup codes — Case-insensitive alphanumeric, single-use, stored in cache with year-2999 expiry. Generated via console command.
- Domain awareness —
DomainManagerhandles multi-part TLDs (.co.uk,.com.au, etc.) with a built-in TLD lookup table. - Interfaces —
LoginInterface,DomainInterface,BackupCodeInterfaceextracted to support testing (mockable).
Test Suite Status
Current Results
| Metric | Value |
|---|---|
| Tests | 293 |
| Assertions | 605 |
| Pass | 222 (100%) |
| Fail | 0 |
| Errors | 0 |
| Warnings | 0 |
| Time | ~0.56s (without coverage) |
| ~1.31s (with coverage) |
Code Coverage
| Metric | Percentage |
|---|---|
| Lines | 100.00% (442/442) |
| Methods | 100.00% (83/83) |
| Classes | 100.00% (21/21) |
Every class, method, and line in src/ is covered.
Source → Test Mapping
| Source File | Test File | Type |
|---|---|---|
Clock.php |
Unit/ClockTest.php |
Unit |
ConfigBag.php |
Unit/ConfigBagTest.php |
Unit |
Kernel.php |
(covered via functional tests) | Functional |
MonitorCacheKeys.php |
Unit/MonitorCacheKeysTest.php |
Unit |
PersistCache.php |
Unit/PersistCacheTest.php |
Unit |
Utilities.php |
Unit/UtilitiesTest.php |
Unit |
Command/GenerateBackupCodesCommand.php |
Unit/Command/GenerateBackupCodesCommandTest.php |
Unit |
Data/Payload.php |
Unit/Data/PayloadTest.php |
Unit |
Enum/Scope.php |
Unit/Enum/ScopeTest.php |
Unit |
Listener/AcceptListener.php |
Unit/Listener/AcceptListenerTest.php |
Unit |
Listener/PublicAccessListener.php |
Unit/Listener/PublicAccessListenerTest.php |
Unit |
Listener/AllowListener.php |
Unit/Listener/AllowListenerTest.php |
Unit |
Listener/InterceptListener.php |
Unit/Listener/InterceptListenerTest.php |
Unit |
Listener/LoginListener.php |
Unit/Listener/LoginListenerTest.php |
Unit |
Listener/RejectListener.php |
Unit/Listener/RejectListenerTest.php |
Unit |
Service/BackupCodeManager.php |
Unit/Service/BackupCodeManagerTest.php |
Unit |
Service/DomainManager.php |
Unit/Service/DomainManagerTest.php |
Unit |
Service/PublicPathMatcher.php |
Unit/Service/PublicPathMatcherTest.php |
Unit |
Service/LoginManager.php |
Unit/Service/LoginManagerTest.php |
Unit |
Trait/CookieNameTrait.php |
Unit/Trait/CookieNameTraitTest.php |
Unit |
Trait/GetTotpTrait.php |
Unit/Trait/GetTotpTraitTest.php |
Unit |
Trait/HasLoggerTrait.php |
Unit/Trait/HasLoggerTraitTest.php |
Unit |
Trait/MakeNonceTrait.php |
Unit/Trait/MakeNonceTraitTest.php |
Unit |
Trait/StringTrait.php |
Unit/Trait/StringTraitTest.php |
Unit |
| (All listeners + services) | Functional/AuthenticationFlowTest.php |
Functional |
| (Public access flow) | Functional/PublicAccessFlowTest.php |
Functional |
Test Quality Assessment
Strengths:
- 100% coverage — every line, method, and class.
- Well-structured test hierarchy — Unit tests per class, functional
tests for the full HTTP kernel flow. Two support traits
(
TotpTestHelper,ListenerTestHelper) provide reusable fixtures (frozen clock, deterministic TOTP, Twig environment, mock rate limiters). - Edge cases well-covered — ULID collision handling, nonce collision retries, spent nonces, invalid payloads (bad base64, non-object JSON, arrays, null, booleans), empty/whitespace fields, field truncation, multibyte characters in cache keys, multi-part TLD domain matching, cookie pruning on invalid sessions.
- Both positive and negative paths — Every listener tests both success and failure scenarios.
- Security-conscious testing — Backup code single-use enforcement, case-insensitivity, character stripping, rate limit teapot vs. too-many-requests, return URL validation (prevents open redirect), cookie security attributes.
- Realistic functional tests —
AuthenticationFlowTestgoes through the actual Symfony kernel: fetches nonces from rendered HTML, submits TOTP codes, verifies cookies are set, tests the full login → authenticated access cycle. - Smart test infrastructure —
KernelBrowser::disableReboot()used in functional tests so nonces persist across requests (matching production APCu behavior).
Status: Test suite goal is met. 222 tests, 100% coverage, all passing.
Roadmap
Phase 1 — Public but Rate-Limited Access ✅ Completed (v1.1)
Goal: Allow select services to be publicly accessible (no TOTP required) but with aggressive per-IP rate limiting to prevent bot traffic from overwhelming the server.
Context: The user previously made Gitea semi-public (view but no login), but bot traffic slowed the server and consumed all household bandwidth, forcing it back to fully private. The solution isn't more authentication — it's bandwidth/resource protection for public-facing services.
Implementation:
-
New config variables:
PUBLIC_PATHS— Comma-separated path patterns with*(single segment) and**(cross-segment) wildcard support. Optional host prefix (e.g.,code.example.com/public/**). When empty (default), the feature is fully disabled.PUBLIC_BURST_COUNT/PUBLIC_BURST_TIME— Burst rate limiting (default: 100 requests per 60 seconds).PUBLIC_UPPER_COUNT/PUBLIC_UPPER_TIME— Sustained rate limiting (default: 500 requests per 3600 seconds).
-
New listener: PublicAccessListener (priority 84, after AcceptListener and AllowListener, before RejectListener):
- Checks if the request path matches a configured public path pattern.
- If public and within rate limit →
200 OK(noRemote-Userheader). - If public and over rate limit →
429 Too Many RequestswithRetry-Afterheader. - Authenticated users bypass this listener entirely (AcceptListener or AllowListener returns 200 first).
-
New service: PublicPathMatcher — Parses path patterns and matches request paths with wildcard support.
-
Separate
public_limitercompound rate limiter (independent from the login attempt rate limiter). -
Design public path detection mechanism (path-based with wildcards)
-
Implement
PublicAccessListenerwith 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):
{ "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 allbackup_prefixed keys from cache - ✅
BackupCodeManager::verifyAndConsume()— Validates and marks code as used (sets value tofalse, 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-codescommand — 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-codescommand — Wrap the existingBackupCodeManager::expire()method in a console command. Should:- Show how many codes are being expired before confirmation
- Support
--forceflag to skip confirmation prompt - Call
persistCache->boot()andpersistCache->persist()like the generate command does (sinceKernel::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.shinfrastructure) - ntfy
- Email (once email integration is available)
- Webhook (generic HTTP POST for future integrations)
- Config variables:
BACKUP_CODE_NOTIFY=discord,ntfy— comma-separated channelsBACKUP_CODE_NOTIFY_WEBHOOK=''— generic webhook URL
- Message should include: timestamp, IP address, username, and how many valid codes remain
- Architecture:
BackupCodeManagerdispatches 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.
- Discord webhook (we already have the
-
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-codescommand 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:
- Client requests a challenge (preauth generates and stores a challenge nonce, similar to the existing nonce system)
- Browser prompts for biometric/PIN, creates a signed assertion
- 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
LoginListenerneed to handle an additional round-trip. The existing nonce + AJAX pattern in_script.html.twigis a good foundation — extend it with a "use passkey" button that initiates thenavigator.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-frameworkintegration 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
PasskeyManagerservice (implements a sharedAuthMethodInterface?) -
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
usernamefield in Payload already supports this — sessions are already tagged with an ID) - Console command to add/remove/list users
- Consider: should the login page ask for a username, or should all TOTP codes be tried against all secrets? (Username is better — it's already in the payload.)
- Tests for multi-user scenarios
Phase 4 — Polish & Hardening
Goal: Production hardening and quality-of-life improvements.
- Docker image improvements:
- Multi-arch builds (amd64 + arm64 for Raspberry Pi)
- Smaller image size (alpine-based if feasible)
- Better health check (actual endpoint, not just
curl localhost)
- GitHub/Gitea repository polish:
- ✅ Comprehensive README with setup guide, architecture overview, and configuration reference
- Contributing guidelines
- ✅ Changelog formalised (CHANGELOG.md)
- ✅ CI workflows (tests + php-cs-fixer on push/PR, Docker image on tag)
- Security review:
- ✅ CSRF protection on the POST form login — nonce system documented
- ✅ Security headers added (X-Content-Type-Options, X-Frame-Options, CSP, etc.)
- Review nonce entropy and cache key collision space
- Consider session fixation protections
- Frontend improvements:
- Mobile-responsive login page audit
- Accessibility audit (ARIA labels, keyboard navigation)
- Dark mode (if not already — the teal background suggests it might already be dark-themed)
- Logging improvements:
- Structured logging (JSON format option) for easier parsing
- Log rotation configuration
- Debug mode documentation
Feature Thoughts
Based on the review, here are features that might be missing or worth considering, keeping in mind that preauth is a gate, not a full identity provider:
High Value
-
Public but rate-limited mode (Phase 1) — Directly solves the Gitea bot traffic problem. This is the most impactful missing feature.
-
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.
-
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.
-
Backup code management commands (Phase 2b) — The
generatecommand exists, butlistandexpirecommands are missing despite the underlying methods (expire()) already being implemented. -
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.
-
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
-
Health check endpoint — The Dockerfile has a
HEALTHCHECKthat justcurls localhost, but a dedicated/healthendpoint that verifies cache connectivity would be more meaningful. -
Graceful degradation — If the file-based cache is corrupted or unavailable, does preauth fail open or closed? Should be documented and tested. (Currently the
PersistCachehandles this inboot(), but edge cases around partial corruption could be explored.) -
Rate limit headers — Adding
X-RateLimit-RemainingandRetry-Afterheaders to rate-limited responses would help legitimate clients back off gracefully.
Lower Value (Nice to Have)
-
WebSocket support — If protected services use WebSocket connections, does
forward_authhandle the upgrade handshake? This is likely a Caddy configuration concern, but worth documenting. -
Theming presets — Beyond the current env-var colour config, preset themes or custom CSS upload could be nice for personalisation.
-
TOTP secret rotation — Console command to generate a new TOTP secret and invalidate all existing sessions. Useful if a device is lost or compromised.
-
Per-service authentication policies — Different services could require different authentication strength (e.g., Bitwarden requires TOTP + recent login, Microbin accepts any valid session). This would need Caddy configuration support to pass the policy to preauth.
Branch Status
| Branch | Status | Notes |
|---|---|---|
main (0.10.0) |
Production | Current stable release |
All feature branches have been pruned. Development uses a feature-branch + PR workflow into main.
Relationship to Other Projects
| Project | Integration |
|---|---|
| MCP server | Preauth could be registered as an MCP command for session management ("revoke all sessions", "who's logged in?") |
| Email integration | Audit log entries could be included in morning summary ("2 failed login attempts from 203.0.113.50 overnight") |
| Discord/ntfy | Alert on backup code usage, suspicious activity (rate limit triggered, multiple failed attempts from new IP), low backup code count |
Prepared by Lyra, your office-side assistant. ✨