Tests / test (pull_request) Successful in 1m10s
Brings preauth from 18/34 to 30/34 conformance (auth-gateway profile). The
remaining four checks all depend on files this branch cannot change (see below).
PHP toolchain (§1)
- require.php >=8.4 -> ^8.5, and pin config.platform to 8.5.0. The old
constraint also permitted PHP 9, which is not a promise we can keep.
composer.lock regenerated with --lock: content-hash + platform-overrides
only, zero dependency version movement.
- friendsofphp/php-cs-fixer * -> ^3.95. A wildcard meant CI was not
reproducible.
PHPStan (§2.2)
- vendor the shared phpstan.neon.dist (level 6) + a generated baseline.
187 errors are captured rather than fixed; the baseline should only shrink
from here.
- add phpstan/phpstan:^2.1 to require-dev.
Code style (§8.2)
- vendor the shared .php-cs-fixer.dist.php (@Symfony + @Symfony:risky +
declare_strict_types) and apply it: 59 of 67 files reformatted.
- Verified this is a formatting change, not a behaviour change: all 313 tests
pass after the reformat, all in_array() calls already passed strict=true,
and the remaining edits are @Symfony:risky idiom (yoda conditions, \count(),
self:: over the class name).
Repository layout (§4.4)
- docs/{Caddyfile,compose.yaml,example.env} -> docs/examples/, with
example.env becoming the conventional .env.example. This is the layout
GUIDING-LIGHT already cites preauth as doing correctly — it just needed
renaming.
- update the four readme.md references and a stale compose.yaml comment.
- docs/v1.1-plan.md references are left alone deliberately: it is a historical
plan recording what was done at the time, not live documentation.
Licence and security policy (§7)
- add LICENSE (uniform MIT, matching composer.json).
- add SECURITY.md describing the actual threat model: per-request
allow/intercept, no caching of the login flow, app-set security headers,
TOTP, and the fact that REMOTE_USER is trusted input.
Mobile accessibility (§3.3a)
- templates/base.html.twig: drop maximum-scale=1 and add viewport-fit=cover.
preauth was the one app already past the font-size precondition (controls
render at 21.6px = 0.9em x 24px), so removing the lock is safe here and
restores pinch-zoom for Android users.
Conformance tooling (§8.2)
- vendor .ci/conformance.sh and .ci/css-control-size.py so the check runs
from a checkout rather than fetching from the LAN-only private/ci.
- .editorconfig synced from the version that keeps the Caddyfile tab rule.
Not included (blocked by the .gitea/workflows pre-receive hook):
- ci-composer-audit, ci-composer-validate, ci-reusable-workflows.
Workflow files may only change via a trusted ref, so the caller files are
staged but not committed.
Also not included: dockerfile-nonroot (§6.4). Adding USER to an image with
VOLUME [/config, /data] changes volume ownership and needs an actual container
build/run to verify, so it goes in its own change.
310 lines
12 KiB
Markdown
310 lines
12 KiB
Markdown
# Preauth
|
|
|
|
A lightweight TOTP authentication gateway for self-hosted web services.
|
|
|
|
Preauth sits between your reverse proxy (Caddy) and your web service,
|
|
requiring a TOTP code before traffic ever reaches the protected application.
|
|
It is **not** a replacement for your service's own authentication — it's a
|
|
gate that prevents outsiders from even seeing what service is running.
|
|
|
|
For when you want a belt and suspenders.
|
|
|
|
## Features
|
|
|
|
- **TOTP authentication** — Time-based one-time passwords (compatible with
|
|
Google Authenticator, Authy, 1Password, etc.)
|
|
- **Backup codes** — Single-use backup codes for when TOTP devices are lost
|
|
- **Caddy native** — Designed for Caddy's `forward_auth` directive
|
|
- **Docker-first** — Single container, persistent volumes, no database
|
|
- **Rate limiting** — Per-IP burst and sustained limits (cannot be disabled)
|
|
- **Public rate-limited access** — Optional, allow unauthenticated access
|
|
to specific paths with separate rate limiting (e.g., public Gitea repos)
|
|
- **Central auth** — Optional subdomain-based SSO across multiple services
|
|
- **IP-based bypass** — Optional, for services that don't handle cookies
|
|
- **Customizable** — Colors, labels, messages, and error text via env vars
|
|
- **Teapot mode** — Respond with `418 I'm a Teapot` when rate-limited
|
|
(because it's more fun than `429 Too Many Requests`)
|
|
- **Cookie security** — `__Host-` prefixed cookies with `SameSite=Strict`,
|
|
`Secure`, and `HttpOnly`
|
|
- **Nonce system** — Single-use nonces prevent replay and CSRF attacks
|
|
- **Dual-layer cache** — APCu for speed, file-based persistence for restarts
|
|
|
|
## Quick Start
|
|
|
|
### 1. Pull the Docker image
|
|
|
|
```bash
|
|
docker pull digitaladapt/preauth:latest
|
|
```
|
|
|
|
### 2. Create your environment file
|
|
|
|
```bash
|
|
# Generate a TOTP secret to get started
|
|
openssl rand -base64 30
|
|
```
|
|
|
|
Create a `.env` file (see `docs/examples/.env.example` for all options):
|
|
|
|
```env
|
|
APP_SECRET=your-random-secret-here
|
|
TOTP_URI=otpauth://totp/Preauth?secret=YOUR_SECRET
|
|
COOKIE_TTL=2592000
|
|
```
|
|
|
|
> If `TOTP_URI` is left blank, the app will generate one on first run
|
|
> and print it to the container logs. Copy it to your `.env` file.
|
|
|
|
### 3. Start the container
|
|
|
|
```bash
|
|
docker compose up -d
|
|
```
|
|
|
|
See `docs/examples/compose.yaml` for an example Docker Compose file.
|
|
|
|
### 4. Configure Caddy
|
|
|
|
```caddyfile
|
|
service.example.com {
|
|
forward_auth preauth {
|
|
uri {uri}
|
|
copy_headers Remote-User
|
|
|
|
# keep the login flow out of browser/proxy caches
|
|
header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0"
|
|
header_down Pragma "no-cache"
|
|
header_down Expires "0"
|
|
header_down Surrogate-Control "no-store"
|
|
header_down Vary "*"
|
|
}
|
|
reverse_proxy your-service:80
|
|
}
|
|
```
|
|
|
|
See `docs/examples/Caddyfile` for more examples, including path-specific protection
|
|
and central auth subdomain configuration. The `header_down` lines above are
|
|
optional — preauth already sends these headers itself — but they guarantee
|
|
at the edge that no part of the login flow is ever cached. (2xx auth
|
|
responses are consumed by `forward_auth` and never reach the browser, so
|
|
your service's own cache headers are unaffected.)
|
|
|
|
### 5. Generate backup codes (optional)
|
|
|
|
```bash
|
|
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
|
|
```
|
|
|
|
## Requirements
|
|
|
|
- **Docker** — Preauth runs as a Docker container
|
|
- **Caddy** — As your reverse proxy (uses `forward_auth` directive)
|
|
- **A web service** — The application you want to protect
|
|
|
|
Other reverse proxies with similar `forward_auth` / `auth_request`
|
|
capabilities may work, but only Caddy is officially supported.
|
|
|
|
## Configuration
|
|
|
|
All configuration is via environment variables. See `docs/examples/.env.example`
|
|
for the complete reference.
|
|
|
|
### Main Options
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `TOTP_URI` | _(empty)_ | TOTP provisioning URI. If blank, one is generated on first run. |
|
|
| `COOKIE_TTL` | `2592000` | Session duration in seconds (default: 30 days). |
|
|
| `SUBDOMAIN_REDIRECT` | `0` | Enable central auth across subdomains (boolean). |
|
|
| `AUTH_SUBDOMAIN` | _(empty)_ | Hostname for central auth (e.g., `auth.example.com`). |
|
|
|
|
### Extra Options
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). |
|
|
| `TEAPOT` | `1` | Respond with 418 instead of 429 when rate-limited (boolean). |
|
|
| `MAX_REQUESTS` | `500` | Restart each FrankenPHP worker thread after this many requests to contain memory growth (`0` = unlimited). Maps to the Caddyfile `max_requests` directive. |
|
|
|
|
### Remote-User Header
|
|
|
|
The `Remote-User` header sent to backends on successful auth is configurable:
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `REMOTE_USER` | `session` | Mode: `session`, `static`, `mapped`, or `none`. |
|
|
| `REMOTE_USER_STATIC` | `authenticated` | Value sent when mode is `static`. |
|
|
| `REMOTE_USER_MAP` | _(empty)_ | Comma-separated map for `mapped` mode (e.g. `alice:admin,bob:user`). |
|
|
|
|
- **`session`** (default): Sends the session id. Backward-compatible.
|
|
- **`static`**: Sends a fixed string for all authenticated requests.
|
|
- **`mapped`**: Looks up the session id in the map; falls back to session id if not found.
|
|
- **`none`**: Omits the header entirely (Caddy still accepts based on status code).
|
|
|
|
### Rate Limiting
|
|
|
|
Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `BURST_COUNT` | `2` | Max attempts per burst window. |
|
|
| `BURST_TIME` | `30` | Burst window in seconds. |
|
|
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
|
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
|
|
|
### Public Rate-Limited Access
|
|
|
|
Preauth can provide rate-limited unauthenticated access to select public
|
|
paths. This is useful for exposing public content (e.g., public repositories
|
|
in Gitea) without requiring TOTP authentication, while protecting server
|
|
resources from bot traffic.
|
|
|
|
When `PUBLIC_PATHS` is configured, requests to matching paths from
|
|
unauthenticated users are allowed through with a separate rate limiter.
|
|
Authenticated users bypass the public rate limiter entirely.
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. See below. |
|
|
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP. |
|
|
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds. |
|
|
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP. |
|
|
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour). |
|
|
|
|
**Path pattern syntax:**
|
|
|
|
- Patterns are matched against the request path only (query string ignored).
|
|
- Patterns must start with `/`.
|
|
- `*` matches one or more characters within a single path segment (not crossing `/`).
|
|
- `**` matches zero or more characters including `/` (crosses path segments).
|
|
- An optional host prefix can restrict a pattern to a specific host
|
|
(e.g., `code.example.com/public/**`).
|
|
|
|
| Pattern | Matches | Does NOT match |
|
|
|---------|---------|----------------|
|
|
| `/public` | `/public` | `/public/`, `/public/repo` |
|
|
| `/public/*` | `/public/repo` | `/public`, `/public/a/b` |
|
|
| `/public/**` | `/public/repo`, `/public/a/b/c` | `/public` |
|
|
| `host.com/api/**` | `host.com/api/v1/status` | `other.com/api/v1/status` |
|
|
|
|
**Example:** Allow public access to Gitea's `/public/` paths:
|
|
|
|
```env
|
|
PUBLIC_PATHS=/public/**
|
|
PUBLIC_BURST_COUNT=100
|
|
PUBLIC_BURST_TIME=60
|
|
PUBLIC_UPPER_COUNT=500
|
|
PUBLIC_UPPER_TIME=3600
|
|
```
|
|
|
|
When a visitor exceeds the rate limit, they receive a `429 Too Many Requests`
|
|
response with a `Retry-After` header. When within limits, they receive a
|
|
`200 OK` response (with no `Remote-User` header). Authenticated users receive
|
|
`200 OK` with their `Remote-User` header as normal.
|
|
|
|
### Styling
|
|
|
|
All UI text and colors are configurable:
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `TITLE` | `Pre-Authentication System` | Page title. |
|
|
| `BG_COLOR` | `#029386` | Background color. |
|
|
| `FG_COLOR` | `#ffffff` | Foreground (text) color. |
|
|
| `ERROR_COLOR` | `#ffb16d` | Error message color. |
|
|
| `ID_NAME` | `Session ID` | Label for the ID field. |
|
|
| `TOKEN_NAME` | `Authentication Token` | Label for the TOTP field. |
|
|
| `SUBMIT_NAME` | `Submit` | Submit button text. |
|
|
| `ERROR_MESSAGE` | `Unsuccessful login attempt` | Failed login message. |
|
|
| `TEAPOT_TITLE` | `I'm a teapot` | Title when rate-limited (teapot mode). |
|
|
| `TEAPOT_MESSAGE` | `I refuse to brew coffee` | Message when rate-limited (teapot mode). |
|
|
| `TOO_MANY_TITLE` | `Too many requests` | Title when rate-limited (non-teapot). |
|
|
| `TOO_MANY_MESSAGE` | `Try again later` | Message when rate-limited (non-teapot). |
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Client → Caddy → forward_auth → Preauth listeners → 200/401/418
|
|
```
|
|
|
|
Preauth is entirely event-listener-driven (no controllers). Each request
|
|
passes through a priority-ordered chain of listeners:
|
|
|
|
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
|
|
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
|
|
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
|
allows rate-limited unauthenticated access to matching paths.
|
|
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
|
5. **LoginListener** (priority 66) — Processes login attempts.
|
|
6. **InterceptListener** (priority 55) — Renders login page or redirects.
|
|
7. **SecurityHeadersListener** (response) — Adds security headers.
|
|
|
|
### Security Model
|
|
|
|
- **Cookies**: `__Host-` prefixed, `SameSite=Strict`, `Secure`, `HttpOnly`
|
|
- **Nonces**: 15-byte random, single-use, 120-second TTL
|
|
- **TOTP**: ±1 period leeway (±30 seconds) for clock drift
|
|
- **Backup codes**: Case-insensitive, single-use, alphanumeric
|
|
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
|
|
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
|
|
Referrer-Policy, HSTS
|
|
- **No cacheable login flow**: The login page, failed logins, redirects,
|
|
and rate-limit pages are sent with strict anti-caching headers
|
|
(`no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0,
|
|
s-maxage=0` plus `Pragma`, `Expires`, `Surrogate-Control`, and
|
|
`Vary: *`), and the login form's `fetch()` opts out of the HTTP cache.
|
|
Successful (2xx) responses are deliberately excluded — they are
|
|
consumed by the proxy's `forward_auth` check and never reach the
|
|
browser, so a protected service's own caching is not affected.
|
|
|
|
### Cache
|
|
|
|
Preauth uses a dual-layer cache:
|
|
- **APCu** (in-memory) — Fast session and nonce lookups
|
|
- **Filesystem** — Persistent storage for container restarts
|
|
|
|
`MonitorCacheKeys` wraps the PSR-6 cache pool to track changes, so only
|
|
modified items are persisted to disk on shutdown.
|
|
|
|
## Development
|
|
|
|
### Code Style
|
|
|
|
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and
|
|
includes `php-cs-fixer` as a dev dependency.
|
|
|
|
```bash
|
|
# Check for style violations
|
|
vendor/bin/php-cs-fixer fix --dry-run --diff
|
|
|
|
# Auto-fix
|
|
vendor/bin/php-cs-fixer fix
|
|
```
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
vendor/bin/phpunit
|
|
```
|
|
|
|
The test suite includes 293 tests with 100% code coverage (lines, methods,
|
|
and classes). Both unit tests and functional tests (full HTTP kernel flow)
|
|
are included.
|
|
|
|
### Requirements
|
|
|
|
- PHP 8.4+
|
|
- Composer
|
|
- Xdebug (for coverage reports)
|
|
|
|
## License
|
|
|
|
MIT — see `license.txt`.
|
|
|
|
## Project Status
|
|
|
|
Running in production since June 2024, protecting multiple self-hosted
|
|
services. The core authentication gate is complete and battle-tested.
|
|
|
|
See `ROADMAP.md` for planned features and `CHANGELOG.md` for version history.
|