Files
lyra 5563999525
Sync GitHub / sync (push) Successful in 8s
feat: public rate-limited access for v1.1
Add PublicAccessListener (priority 84) that allows rate-limited
unauthenticated access to configured public paths. Authenticated users
bypass this listener entirely via AcceptListener/AllowListener.

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

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

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

Documentation: README, CHANGELOG, ROADMAP, Caddyfile, example.env
all updated with public access configuration and examples.
2026-08-12 09:26:50 -04:00

488 lines
22 KiB
Markdown

# Preauth — Project Roadmap
## Project Overview
Preauth is a pre-authentication gate for self-hosted services. It sits
between a reverse proxy (Caddy's `forward_auth`) and your web service,
requiring a TOTP code (or backup code) before traffic ever reaches the
protected application. It is **not** a replacement for the service's own
authentication — it's a gate that prevents outsiders from even seeing
what service is running.
- **Location:** `projects/preauth/`
- **Framework:** Symfony 7.4 (PHP ≥ 8.4)
- **Serving:** FrankenPHP (Docker image)
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
- **Auth:** TOTP (single secret) + single-use backup codes
- **Production status:** Running in production since June 2024
### Current Production Use
| Service | Purpose |
|-------------|--------------------------------------------------|
| Bitwarden | Password manager — always accessible, invisible to the world |
| Microbin | Sharing text blobs and small files across devices |
| Gitea | Code hosting — some DNS configs must be public |
---
## Architecture
### Request Flow
```
Client → Caddy → forward_auth → Preauth listeners (priority order) → 200/401/418
```
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
If found → `200 OK` + `Remote-User` header → Caddy proxies to backend.
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
valid IP-based session. If found → `200 OK` + `Remote-User`.
3. **PublicAccessListener** (priority 84) — If `PUBLIC_PATHS` is
configured and the request matches a public path pattern, applies
per-IP rate limiting. Within limit → `200 OK`. Over limit → `429`.
Authenticated users never reach this listener.
4. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
exceeded login attempt threshold → `418 I'm a Teapot` (or `429`).
5. **LoginListener** (priority 66) — Detects login attempts via
`X-Preauth` header (base64url JSON) or POST form on auth subdomain.
Validates TOTP/backup codes through `LoginManager`.
6. **InterceptListener** (priority 55) — Fallback: if no listener has
set a response, either redirects to auth subdomain (central auth) or
renders the Twig login page with a fresh nonce.
### Key Design Decisions
- **No controllers** — Entirely event-listener-driven. Clean separation
of concerns, each listener handles one stage of the auth flow.
- **Dual-layer cache** — APCu for fast in-memory lookups, file-based
storage for persistence across container restarts. `MonitorCacheKeys`
wraps the PSR-6 pool to track key changes for efficient persistence
(only write what changed).
- **`__Host-` prefixed cookies** — `SameSite=Strict`, `Secure`,
`HttpOnly`. Central auth mode uses a separate `__Http-Domain-Preauth`
cookie name (domain-scoped, no `__Host-` prefix).
- **Nonce system** — 15-byte random nonces, single-use, 120s TTL, with
retry-on-collision (up to 3 attempts).
- **TOTP with ±1 period leeway (±30 seconds)** — Accommodates clock drift.
- **Backup codes** — Case-insensitive alphanumeric, single-use, stored
in cache with year-2999 expiry. Generated via console command.
- **Domain awareness** — `DomainManager` handles multi-part TLDs
(`.co.uk`, `.com.au`, etc.) with a built-in TLD lookup table.
- **Interfaces** — `LoginInterface`, `DomainInterface`,
`BackupCodeInterface` extracted to support testing (mockable).
---
## Test Suite Status
### Current Results
| Metric | Value |
|--------------|--------------------------------|
| **Tests** | 293 |
| **Assertions** | 605 |
| **Pass** | 222 (100%) |
| **Fail** | 0 |
| **Errors** | 0 |
| **Warnings** | 0 |
| **Time** | ~0.56s (without coverage) |
| | ~1.31s (with coverage) |
### Code Coverage
| Metric | Percentage |
|----------|---------------------|
| **Lines** | **100.00%** (442/442) |
| **Methods** | **100.00%** (83/83) |
| **Classes** | **100.00%** (21/21) |
Every class, method, and line in `src/` is covered.
### Source → Test Mapping
| Source File | Test File | Type |
|------------------------------------------|----------------------------------------------------|----------|
| `Clock.php` | `Unit/ClockTest.php` | Unit |
| `ConfigBag.php` | `Unit/ConfigBagTest.php` | Unit |
| `Kernel.php` | (covered via functional tests) | Functional |
| `MonitorCacheKeys.php` | `Unit/MonitorCacheKeysTest.php` | Unit |
| `PersistCache.php` | `Unit/PersistCacheTest.php` | Unit |
| `Utilities.php` | `Unit/UtilitiesTest.php` | Unit |
| `Command/GenerateBackupCodesCommand.php` | `Unit/Command/GenerateBackupCodesCommandTest.php` | Unit |
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.php` | Unit |
| `Listener/AcceptListener.php` | `Unit/Listener/AcceptListenerTest.php` | Unit |
| `Listener/PublicAccessListener.php` | `Unit/Listener/PublicAccessListenerTest.php` | Unit |
| `Listener/AllowListener.php` | `Unit/Listener/AllowListenerTest.php` | Unit |
| `Listener/InterceptListener.php` | `Unit/Listener/InterceptListenerTest.php` | Unit |
| `Listener/LoginListener.php` | `Unit/Listener/LoginListenerTest.php` | Unit |
| `Listener/RejectListener.php` | `Unit/Listener/RejectListenerTest.php` | Unit |
| `Service/BackupCodeManager.php` | `Unit/Service/BackupCodeManagerTest.php` | Unit |
| `Service/DomainManager.php` | `Unit/Service/DomainManagerTest.php` | Unit |
| `Service/PublicPathMatcher.php` | `Unit/Service/PublicPathMatcherTest.php` | Unit |
| `Service/LoginManager.php` | `Unit/Service/LoginManagerTest.php` | Unit |
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.php` | Unit |
| `Trait/GetTotpTrait.php` | `Unit/Trait/GetTotpTraitTest.php` | Unit |
| `Trait/HasLoggerTrait.php` | `Unit/Trait/HasLoggerTraitTest.php` | Unit |
| `Trait/MakeNonceTrait.php` | `Unit/Trait/MakeNonceTraitTest.php` | Unit |
| `Trait/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
| *(Public access flow)* | `Functional/PublicAccessFlowTest.php` | Functional |
### Test Quality Assessment
**Strengths:**
- **100% coverage** — every line, method, and class.
- **Well-structured test hierarchy** — Unit tests per class, functional
tests for the full HTTP kernel flow. Two support traits
(`TotpTestHelper`, `ListenerTestHelper`) provide reusable fixtures
(frozen clock, deterministic TOTP, Twig environment, mock rate
limiters).
- **Edge cases well-covered** — ULID collision handling, nonce collision
retries, spent nonces, invalid payloads (bad base64, non-object JSON,
arrays, null, booleans), empty/whitespace fields, field truncation,
multibyte characters in cache keys, multi-part TLD domain matching,
cookie pruning on invalid sessions.
- **Both positive and negative paths** — Every listener tests both
success and failure scenarios.
- **Security-conscious testing** — Backup code single-use enforcement,
case-insensitivity, character stripping, rate limit teapot vs.
too-many-requests, return URL validation (prevents open redirect),
cookie security attributes.
- **Realistic functional tests** — `AuthenticationFlowTest` goes through
the actual Symfony kernel: fetches nonces from rendered HTML, submits
TOTP codes, verifies cookies are set, tests the full login →
authenticated access cycle.
- **Smart test infrastructure** — `KernelBrowser::disableReboot()` used
in functional tests so nonces persist across requests (matching
production APCu behavior).
**Status: Test suite goal is met.** 222 tests, 100% coverage, all passing.
---
## Roadmap
### Phase 1 — Public but Rate-Limited Access ✅ Completed (v1.1)
**Goal:** Allow select services to be publicly accessible (no TOTP
required) but with aggressive per-IP rate limiting to prevent bot
traffic from overwhelming the server.
**Context:** The user previously made Gitea semi-public (view but no
login), but bot traffic slowed the server and consumed all household
bandwidth, forcing it back to fully private. The solution isn't more
authentication — it's bandwidth/resource protection for public-facing
services.
**Implementation:**
- New config variables:
- `PUBLIC_PATHS` — Comma-separated path patterns with `*` (single
segment) and `**` (cross-segment) wildcard support. Optional host
prefix (e.g., `code.example.com/public/**`). When empty (default),
the feature is fully disabled.
- `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` — Burst rate limiting
(default: 100 requests per 60 seconds).
- `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` — Sustained rate limiting
(default: 500 requests per 3600 seconds).
- New listener: **PublicAccessListener** (priority 84, after
AcceptListener and AllowListener, before RejectListener):
- Checks if the request path matches a configured public path pattern.
- If public and within rate limit → `200 OK` (no `Remote-User` header).
- If public and over rate limit → `429 Too Many Requests` with
`Retry-After` header.
- Authenticated users bypass this listener entirely (AcceptListener
or AllowListener returns 200 first).
- New service: **PublicPathMatcher** — Parses path patterns and matches
request paths with wildcard support.
- Separate `public_limiter` compound rate limiter (independent from
the login attempt rate limiter).
- [x] Design public path detection mechanism (path-based with wildcards)
- [x] Implement `PublicAccessListener` with separate rate limiter pool
- [x] Add config variables and defaults
- [x] Update Caddyfile example with public service snippet
- [x] Tests for public mode (within limit, over limit, burst behavior)
- [x] Documentation in README
### Phase 2 — Session Management & Audit
**Goal:** Give visibility into who has access and when it was granted.
- [ ] **Active sessions view** — Console command or simple API endpoint
to list active sessions (cookie-based and IP-based), showing:
- Session ID / username
- IP address
- First auth timestamp
- Last seen timestamp
- Scope (cookie vs. IP)
- [ ] **Session revocation** — Console command to revoke a specific
session by ID or revoke all sessions for an IP.
- [ ] **Audit log** — Log every successful and failed authentication
attempt to a persistent store (file-based JSONL, similar to the email
integration's audit log):
```json
{
"timestamp": "2025-01-15T14:23:01Z",
"ip": "192.168.1.50",
"action": "login_success",
"username": "mom",
"method": "totp"
}
```
- [ ] Tests for all new commands and endpoints
### Phase 2b — Backup Code System Completion
**Goal:** Finish the backup code system — the core logic is solid but
the management surface is incomplete.
**What already exists:**
- ✅ `BackupCodeManager::generate()` — Creates codes, saves to cache
with year-2999 expiry
- ✅ `BackupCodeManager::expire()` — Deletes all `backup_` prefixed
keys from cache
- ✅ `BackupCodeManager::verifyAndConsume()` — Validates and marks code
as used (sets value to `false`, keeps the key for audit trail)
- ✅ `app:generate-backup-codes [count]` console command
- ✅ Tests for all of the above (100% coverage)
**What's missing:**
- [ ] **`app:list-backup-codes` command** — Show backup code status:
- Total codes generated
- How many are still valid (unused)
- How many have been spent (and optionally when)
- Output format: table with status column (✅ valid / ⛔ used)
- Note: spent codes are kept in cache with value `false`, so we can
distinguish "used" from "never existed" — this is good design
- [ ] **`app:expire-backup-codes` command** — Wrap the existing
`BackupCodeManager::expire()` method in a console command. Should:
- Show how many codes are being expired before confirmation
- Support `--force` flag to skip confirmation prompt
- Call `persistCache->boot()` and `persistCache->persist()` like the
generate command does (since `Kernel::terminate()` doesn't run in
CLI)
- [ ] **Notification on backup code use** — When
`verifyAndConsume()` consumes a backup code, fire a notification
through configurable channels:
- Discord webhook (we already have the `discord.sh` infrastructure)
- ntfy
- Email (once email integration is available)
- Webhook (generic HTTP POST for future integrations)
- Config variables:
- `BACKUP_CODE_NOTIFY=discord,ntfy` — comma-separated channels
- `BACKUP_CODE_NOTIFY_WEBHOOK=''` — generic webhook URL
- Message should include: timestamp, IP address, username, and how
many valid codes remain
- Architecture: `BackupCodeManager` dispatches an event
(e.g. `BackupCodeUsedEvent`) after consuming a code. A listener
handles the notification dispatch. This keeps the notification
logic out of the backup code manager itself.
- [ ] **Low-codes warning** — If backup codes fall below a threshold
(e.g. 3 remaining), include a warning in the notification and/or
surface it in the `list-backup-codes` command output
- [ ] Tests for all new commands and notification dispatch
### Phase 2c — Passkey Authentication
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
authentication method alongside TOTP and backup codes.
**Context:** Passkeys are the modern standard for passwordless auth.
They're phishing-resistant (domain-bound), use biometrics or device
PINs, and are significantly more user-friendly than typing 6-digit
codes. For a pre-auth gate that friends and family use, passkeys would
be a major UX improvement — especially for non-technical users who
struggle with TOTP apps.
**Design considerations:**
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
secret shared with all devices), each device registers its own
passkey. This is actually better for a family-use gate — you can
register mom's phone separately from dad's laptop.
- WebAuthn requires a **challenge-response flow**:
1. Client requests a challenge (preauth generates and stores a
challenge nonce, similar to the existing nonce system)
2. Browser prompts for biometric/PIN, creates a signed assertion
3. Server verifies the assertion against the registered credential
- This is a **two-step flow** unlike TOTP's single-step, which means
the login page JS and `LoginListener` need to handle an additional
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
is a good foundation — extend it with a "use passkey" button that
initiates the `navigator.credentials.get()` flow.
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
Symfony bundle available). Would add registration ceremony (console
command or initial-setup flow to register a passkey).
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
7.4 and FrankenPHP
- [ ] Design passkey registration flow (console command? first-visit
setup? separate registration endpoint?)
- [ ] Implement challenge generation and storage (extend existing
nonce/cache infrastructure)
- [ ] Implement assertion verification in a new `PasskeyManager`
service (implements a shared `AuthMethodInterface`?)
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
- [ ] Handle multiple registered passkeys (per-device)
- [ ] Console command: `app:list-passkeys` — show registered devices
- [ ] Console command: `app:remove-passkey` — revoke a passkey
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
- [ ] Tests for registration, authentication, and revocation
- [ ] Consider: should passkeys be a *replacement* for TOTP or an
*alternative*? (Probably alternative — keep TOTP as fallback)
### Phase 3 — Multi-User Support
**Goal:** Support multiple TOTP users for household/family access.
*Note: This is a significant feature that changes the single-secret
model. It should only be pursued if the single-secret + backup codes
approach proves insufficient for the use case.*
- [ ] Multiple TOTP secrets, each with a label (e.g., "mom", "dad",
"friend")
- [ ] Per-user backup codes
- [ ] Per-user session tracking (the `username` field in Payload already
supports this — sessions are already tagged with an ID)
- [ ] Console command to add/remove/list users
- [ ] Consider: should the login page ask for a username, or should all
TOTP codes be tried against all secrets? (Username is better —
it's already in the payload.)
- [ ] Tests for multi-user scenarios
### Phase 4 — Polish & Hardening
**Goal:** Production hardening and quality-of-life improvements.
- [ ] **Docker image improvements:**
- Multi-arch builds (amd64 + arm64 for Raspberry Pi)
- Smaller image size (alpine-based if feasible)
- Better health check (actual endpoint, not just `curl localhost`)
- [ ] **GitHub/Gitea repository polish:**
- ✅ Comprehensive README with setup guide, architecture overview, and
configuration reference
- Contributing guidelines
- ✅ Changelog formalised (CHANGELOG.md)
- ✅ CI workflows (tests + php-cs-fixer on push/PR, Docker image on tag)
- [ ] **Security review:**
- ✅ CSRF protection on the POST form login — nonce system documented
- ✅ Security headers added (X-Content-Type-Options, X-Frame-Options, CSP, etc.)
- Review nonce entropy and cache key collision space
- Consider session fixation protections
- [ ] **Frontend improvements:**
- Mobile-responsive login page audit
- Accessibility audit (ARIA labels, keyboard navigation)
- Dark mode (if not already — the teal background suggests it might
already be dark-themed)
- [ ] **Logging improvements:**
- Structured logging (JSON format option) for easier parsing
- Log rotation configuration
- Debug mode documentation
---
## Feature Thoughts
Based on the review, here are features that might be missing or worth
considering, keeping in mind that preauth is a **gate**, not a full
identity provider:
### High Value
1. **Public but rate-limited mode** (Phase 1) — Directly solves the
Gitea bot traffic problem. This is the most impactful missing
feature.
2. **Passkey authentication** (Phase 2c) — Phishing-resistant,
passwordless auth that's far more user-friendly than TOTP for
non-technical family members. The modern standard for this kind
of gate.
3. **Backup code notifications** (Phase 2b) — When a backup code is
used, you should know about it immediately. This is a security-critical
event — it means someone lost their device or is locked out of their
TOTP app. Discord/ntfy/email notification should fire automatically.
4. **Backup code management commands** (Phase 2b) — The `generate`
command exists, but `list` and `expire` commands are missing despite
the underlying methods (`expire()`) already being implemented.
5. **Session visibility and revocation** (Phase 2) — Currently there's
no way to see who has access or revoke a session without clearing
the entire cache. For a security tool, this is important.
6. **Audit log** (Phase 2) — For a security gate, not having an audit
trail of logins (successful and failed) is a gap. The data is logged
at debug level, but not persisted in a queryable format.
### Medium Value
4. **Health check endpoint** — The Dockerfile has a `HEALTHCHECK` that
just `curl`s localhost, but a dedicated `/health` endpoint that
verifies cache connectivity would be more meaningful.
5. **Graceful degradation** — If the file-based cache is corrupted or
unavailable, does preauth fail open or closed? Should be documented
and tested. (Currently the `PersistCache` handles this in `boot()`,
but edge cases around partial corruption could be explored.)
6. **Rate limit headers** — Adding `X-RateLimit-Remaining` and
`Retry-After` headers to rate-limited responses would help legitimate
clients back off gracefully.
### Lower Value (Nice to Have)
7. **WebSocket support** — If protected services use WebSocket
connections, does `forward_auth` handle the upgrade handshake? This
is likely a Caddy configuration concern, but worth documenting.
8. **Theming presets** — Beyond the current env-var colour config,
preset themes or custom CSS upload could be nice for personalisation.
9. **TOTP secret rotation** — Console command to generate a new TOTP
secret and invalidate all existing sessions. Useful if a device is
lost or compromised.
10. **Per-service authentication policies** — Different services could
require different authentication strength (e.g., Bitwarden requires
TOTP + recent login, Microbin accepts any valid session). This would
need Caddy configuration support to pass the policy to preauth.
---
## Branch Status
| Branch | Status | Notes |
|--------|--------|-------|
| `main` (0.10.0) | Production | Current stable release |
All feature branches have been pruned. Development uses a feature-branch + PR workflow into `main`.
---
## Relationship to Other Projects
| Project | Integration |
|---------|-------------|
| MCP server | Preauth could be registered as an MCP command for session management ("revoke all sessions", "who's logged in?") |
| Email integration | Audit log entries could be included in morning summary ("2 failed login attempts from 203.0.113.50 overnight") |
| Discord/ntfy | Alert on backup code usage, suspicious activity (rate limit triggered, multiple failed attempts from new IP), low backup code count |
---
*Prepared by Lyra, your office-side assistant. ✨*