Sync GitHub / sync (push) Successful in 8s
Add PublicAccessListener (priority 84) that allows rate-limited unauthenticated access to configured public paths. Authenticated users bypass this listener entirely via AcceptListener/AllowListener. New components: - PublicPathMatcher service with wildcard path matching (* and **) and optional host-prefix scoping - PublicAccessListener applying per-IP rate limiting to public paths - Separate public_limiter compound rate limiter (burst + sustained) - publicRateLimitCache pool (APCu in prod, array in tests) New env vars: - PUBLIC_PATHS (comma-separated path patterns, empty = disabled) - PUBLIC_BURST_COUNT/PUBLIC_BURST_TIME (default 100/60s) - PUBLIC_UPPER_COUNT/PUBLIC_UPPER_TIME (default 500/3600s) Tests: 52 new tests (29 unit for PublicPathMatcher, 12 unit for PublicAccessListener, 11 functional for PublicAccessFlowTest). Total: 293 tests, 605 assertions, all passing. PHP CS Fixer: 0 of 63 files need fixing. Documentation: README, CHANGELOG, ROADMAP, Caddyfile, example.env all updated with public access configuration and examples.
290 lines
10 KiB
Markdown
290 lines
10 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/example.env` 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/compose.yaml` for an example Docker Compose file.
|
|
|
|
### 4. Configure Caddy
|
|
|
|
```caddyfile
|
|
service.example.com {
|
|
forward_auth preauth {
|
|
uri {uri}
|
|
copy_headers Remote-User
|
|
}
|
|
reverse_proxy your-service:80
|
|
}
|
|
```
|
|
|
|
See `docs/Caddyfile` for more examples, including path-specific protection
|
|
and central auth subdomain configuration.
|
|
|
|
### 5. Generate backup codes (optional)
|
|
|
|
```bash
|
|
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
|
|
```
|
|
|
|
## 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/example.env`
|
|
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). |
|
|
|
|
### Remote-User Header
|
|
|
|
The `Remote-User` header sent to backends on successful auth is configurable:
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `REMOTE_USER` | `session` | Mode: `session`, `static`, `mapped`, or `none`. |
|
|
| `REMOTE_USER_STATIC` | `authenticated` | Value sent when mode is `static`. |
|
|
| `REMOTE_USER_MAP` | _(empty)_ | Comma-separated map for `mapped` mode (e.g. `alice:admin,bob:user`). |
|
|
|
|
- **`session`** (default): Sends the session id. Backward-compatible.
|
|
- **`static`**: Sends a fixed string for all authenticated requests.
|
|
- **`mapped`**: Looks up the session id in the map; falls back to session id if not found.
|
|
- **`none`**: Omits the header entirely (Caddy still accepts based on status code).
|
|
|
|
### Rate Limiting
|
|
|
|
Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `BURST_COUNT` | `2` | Max attempts per burst window. |
|
|
| `BURST_TIME` | `30` | Burst window in seconds. |
|
|
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
|
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
|
|
|
### Public Rate-Limited Access
|
|
|
|
Preauth can provide rate-limited unauthenticated access to select public
|
|
paths. This is useful for exposing public content (e.g., public repositories
|
|
in Gitea) without requiring TOTP authentication, while protecting server
|
|
resources from bot traffic.
|
|
|
|
When `PUBLIC_PATHS` is configured, requests to matching paths from
|
|
unauthenticated users are allowed through with a separate rate limiter.
|
|
Authenticated users bypass the public rate limiter entirely.
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. See below. |
|
|
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP. |
|
|
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds. |
|
|
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP. |
|
|
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour). |
|
|
|
|
**Path pattern syntax:**
|
|
|
|
- Patterns are matched against the request path only (query string ignored).
|
|
- Patterns must start with `/`.
|
|
- `*` matches one or more characters within a single path segment (not crossing `/`).
|
|
- `**` matches zero or more characters including `/` (crosses path segments).
|
|
- An optional host prefix can restrict a pattern to a specific host
|
|
(e.g., `code.example.com/public/**`).
|
|
|
|
| Pattern | Matches | Does NOT match |
|
|
|---------|---------|----------------|
|
|
| `/public` | `/public` | `/public/`, `/public/repo` |
|
|
| `/public/*` | `/public/repo` | `/public`, `/public/a/b` |
|
|
| `/public/**` | `/public/repo`, `/public/a/b/c` | `/public` |
|
|
| `host.com/api/**` | `host.com/api/v1/status` | `other.com/api/v1/status` |
|
|
|
|
**Example:** Allow public access to Gitea's `/public/` paths:
|
|
|
|
```env
|
|
PUBLIC_PATHS=/public/**
|
|
PUBLIC_BURST_COUNT=100
|
|
PUBLIC_BURST_TIME=60
|
|
PUBLIC_UPPER_COUNT=500
|
|
PUBLIC_UPPER_TIME=3600
|
|
```
|
|
|
|
When a visitor exceeds the rate limit, they receive a `429 Too Many Requests`
|
|
response with a `Retry-After` header. When within limits, they receive a
|
|
`200 OK` response (with no `Remote-User` header). Authenticated users receive
|
|
`200 OK` with their `Remote-User` header as normal.
|
|
|
|
### Styling
|
|
|
|
All UI text and colors are configurable:
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `TITLE` | `Pre-Authentication System` | Page title. |
|
|
| `BG_COLOR` | `#029386` | Background color. |
|
|
| `FG_COLOR` | `#ffffff` | Foreground (text) color. |
|
|
| `ERROR_COLOR` | `#ffb16d` | Error message color. |
|
|
| `ID_NAME` | `Session ID` | Label for the ID field. |
|
|
| `TOKEN_NAME` | `Authentication Token` | Label for the TOTP field. |
|
|
| `SUBMIT_NAME` | `Submit` | Submit button text. |
|
|
| `ERROR_MESSAGE` | `Unsuccessful login attempt` | Failed login message. |
|
|
| `TEAPOT_TITLE` | `I'm a teapot` | Title when rate-limited (teapot mode). |
|
|
| `TEAPOT_MESSAGE` | `I refuse to brew coffee` | Message when rate-limited (teapot mode). |
|
|
| `TOO_MANY_TITLE` | `Too many requests` | Title when rate-limited (non-teapot). |
|
|
| `TOO_MANY_MESSAGE` | `Try again later` | Message when rate-limited (non-teapot). |
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Client → Caddy → forward_auth → Preauth listeners → 200/401/418
|
|
```
|
|
|
|
Preauth is entirely event-listener-driven (no controllers). Each request
|
|
passes through a priority-ordered chain of listeners:
|
|
|
|
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
|
|
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
|
|
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
|
allows rate-limited unauthenticated access to matching paths.
|
|
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
|
5. **LoginListener** (priority 66) — Processes login attempts.
|
|
6. **InterceptListener** (priority 55) — Renders login page or redirects.
|
|
7. **SecurityHeadersListener** (response) — Adds security headers.
|
|
|
|
### Security Model
|
|
|
|
- **Cookies**: `__Host-` prefixed, `SameSite=Strict`, `Secure`, `HttpOnly`
|
|
- **Nonces**: 15-byte random, single-use, 120-second TTL
|
|
- **TOTP**: ±1 period leeway (±30 seconds) for clock drift
|
|
- **Backup codes**: Case-insensitive, single-use, alphanumeric
|
|
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
|
|
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
|
|
Referrer-Policy, HSTS
|
|
|
|
### Cache
|
|
|
|
Preauth uses a dual-layer cache:
|
|
- **APCu** (in-memory) — Fast session and nonce lookups
|
|
- **Filesystem** — Persistent storage for container restarts
|
|
|
|
`MonitorCacheKeys` wraps the PSR-6 cache pool to track changes, so only
|
|
modified items are persisted to disk on shutdown.
|
|
|
|
## Development
|
|
|
|
### Code Style
|
|
|
|
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and
|
|
includes `php-cs-fixer` as a dev dependency.
|
|
|
|
```bash
|
|
# Check for style violations
|
|
vendor/bin/php-cs-fixer fix --dry-run --diff
|
|
|
|
# Auto-fix
|
|
vendor/bin/php-cs-fixer fix
|
|
```
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
vendor/bin/phpunit
|
|
```
|
|
|
|
The test suite includes 293 tests with 100% code coverage (lines, methods,
|
|
and classes). Both unit tests and functional tests (full HTTP kernel flow)
|
|
are included.
|
|
|
|
### Requirements
|
|
|
|
- PHP 8.4+
|
|
- Composer
|
|
- Xdebug (for coverage reports)
|
|
|
|
## License
|
|
|
|
MIT — see `license.txt`.
|
|
|
|
## Project Status
|
|
|
|
Running in production since June 2024, protecting multiple self-hosted
|
|
services. The core authentication gate is complete and battle-tested.
|
|
|
|
See `ROADMAP.md` for planned features and `CHANGELOG.md` for version history.
|