Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b960ccea | ||
|
|
17c2d525ff | ||
|
|
72c41fec77 | ||
|
|
29e471c536 | ||
|
|
44e4c60f80 | ||
|
|
5563999525 | ||
|
|
9ad54f8e2a | ||
|
|
2258839bd6 | ||
|
|
408d75dda1 | ||
|
|
b89070e985 | ||
|
|
d2eb914637 |
@@ -12,6 +12,11 @@ BURST_COUNT=10
|
||||
BURST_TIME=30
|
||||
UPPER_COUNT=100
|
||||
UPPER_TIME=3600
|
||||
PUBLIC_PATHS=''
|
||||
PUBLIC_BURST_COUNT=100
|
||||
PUBLIC_BURST_TIME=60
|
||||
PUBLIC_UPPER_COUNT=500
|
||||
PUBLIC_UPPER_TIME=3600
|
||||
TITLE='Pre-Authentication System'
|
||||
BG_COLOR='#029386'
|
||||
FG_COLOR='#ffffff'
|
||||
|
||||
@@ -31,4 +31,3 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_TARGET }}:develop
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Push Docker
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -22,6 +22,10 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -30,5 +34,4 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_TARGET }}:latest
|
||||
${{ vars.DOCKERHUB_TARGET }}:${{ github.ref_name }}
|
||||
|
||||
${{ vars.DOCKERHUB_TARGET }}:${{ steps.version.outputs.VERSION }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
SYNC_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||
SYNC_TARGET: ${{ vars.SYNC_GITHUB_TARGET }}
|
||||
run: |
|
||||
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
|
||||
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET" 2>/dev/null || git remote set-url github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
|
||||
|
||||
- name: Push Current Branch
|
||||
run: |
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
/.php-cs-fixer.php
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
.env
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased] — v1.1
|
||||
|
||||
### Added
|
||||
- **Public rate-limited access** — Select paths can now be made publicly
|
||||
accessible without TOTP authentication, with separate per-IP rate limiting.
|
||||
This is useful for exposing public content (e.g., public Gitea repositories)
|
||||
while protecting server resources from bot traffic.
|
||||
- New `PUBLIC_PATHS` env var: 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.
|
||||
- New `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` env vars for burst rate
|
||||
limiting (default: 100 requests per 60 seconds).
|
||||
- New `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` env vars for sustained
|
||||
rate limiting (default: 500 requests per 3600 seconds).
|
||||
- Authenticated users bypass the public rate limiter entirely.
|
||||
- Over-limit responses include a `Retry-After` header.
|
||||
- New `PublicPathMatcher` service for path pattern matching.
|
||||
- New `PublicAccessListener` (priority 84) in the request pipeline.
|
||||
|
||||
## [1.0.0] — v1.0 Release
|
||||
|
||||
### Security
|
||||
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
|
||||
variable with four modes: `session` (default), `static`, `mapped`, and `none`.
|
||||
This allows deployments to prevent user-controlled header values from reaching
|
||||
backend services.
|
||||
- Added `SecurityHeadersListener` to set `X-Content-Type-Options`, `X-Frame-Options`,
|
||||
`Content-Security-Policy`, `Referrer-Policy`, and `Strict-Transport-Security`
|
||||
headers on all responses.
|
||||
- Replaced `document.write()` with `document.documentElement.innerHTML` in login
|
||||
page JavaScript to avoid CSP violations.
|
||||
- Added CSS escaping (`|e('css')`) to environment-configured color values in
|
||||
the login page template to prevent CSS injection.
|
||||
- Documented CSRF protection model: the nonce system provides CSRF protection
|
||||
for POST form logins (server-generated, single-use, 120s TTL).
|
||||
- Reduced TOTP verification window from 10 periods (±5 minutes) to 1 period
|
||||
(±30 seconds) to reduce brute-force attack surface.
|
||||
- Removed hardcoded `APP_SECRET` from `bin/franken.sh` (now uses environment
|
||||
variable or generates a random secret).
|
||||
- Removed backup code values from debug log output.
|
||||
- Added `.env` to `.gitignore`.
|
||||
- Expanded TLD list in `DomainManager` with many missing multi-part TLDs
|
||||
(`.com.au`, `.co.jp`, `.com.br`, `.co.kr`, `.com.tw`, `.co.za`, etc.)
|
||||
to prevent open redirect vulnerabilities from incorrect domain matching.
|
||||
- Lowercased host before TLD lookup to fix case-sensitivity issue.
|
||||
|
||||
### Fixed
|
||||
- Fixed `$payload->json` access on possibly-null `$payload` in `LoginListener`
|
||||
using null-safe operator (`?->`).
|
||||
- Fixed `validReturn()` not checking `false` return from `parse_url()`, which
|
||||
could cause a `TypeError` on malformed URLs.
|
||||
- Added `isHit()` race condition check in `AcceptListener` and `AllowListener`
|
||||
between `hasItem()` and `getItem()` calls.
|
||||
- Added `try/finally` in `Kernel::terminate()` so `parent::terminate()` always
|
||||
runs even if `persist()` throws an exception.
|
||||
- Added input validation to `GenerateBackupCodesCommand` — rejects count < 1.
|
||||
|
||||
### Changed
|
||||
- Disabled unused Symfony sessions in `framework.yaml` (preauth implements its
|
||||
own cookie/cache-based session management).
|
||||
- Standardized git tag format to use `v` prefix (`v1.0.0` instead of `1.0.0`).
|
||||
- Updated CI workflows to use `v*.*.*` tag pattern and strip `v` prefix for
|
||||
Docker image tags.
|
||||
- Removed stale `develop` branch from CI triggers.
|
||||
- Fixed `publish.yaml` to use `git remote set-url` on re-runs instead of
|
||||
failing when the remote already exists.
|
||||
- Explicitly install `curl` in the Docker final image (needed for healthcheck).
|
||||
- Added `declare(strict_types=1)` to all interface files.
|
||||
- Added `#[AsCommand]` attribute to `GenerateBackupCodesCommand`.
|
||||
- Fixed `BackupCodeInterface` default count to match implementation (10).
|
||||
- Used `Response::HTTP_INTERNAL_SERVER_ERROR` constant in `GetTotpTrait`
|
||||
instead of literal `500`.
|
||||
|
||||
## [0.10.0] - 2026-08-11
|
||||
|
||||
### Added
|
||||
- PHP-CS-Fixer with PSR-12 configuration and CI check.
|
||||
|
||||
## [0.9.0] - 2026-07-15
|
||||
|
||||
### Added
|
||||
- PHPUnit test suite — 222 tests, 100% code coverage (lines, methods, classes).
|
||||
|
||||
## [0.8.1] - 2026-05-30
|
||||
|
||||
### Fixed
|
||||
- Bug fixes and cleanup from develop branch merge.
|
||||
|
||||
## [0.8.0] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
- Renamed form fields for clarity.
|
||||
- Fixed invalid login bug.
|
||||
|
||||
## [0.7.0] - 2026-05-29
|
||||
|
||||
### Added
|
||||
- Single-use backup codes via `app:generate-backup-codes` console command.
|
||||
- Cache persistence improvement — only write changed keys to file storage.
|
||||
|
||||
### Removed
|
||||
- Static password and lookup token (security risks).
|
||||
|
||||
### Changed
|
||||
- Updated to PHP 8.5, updated dependencies.
|
||||
|
||||
## [0.6.0] - 2026-02-10
|
||||
|
||||
### Added
|
||||
- Optional (disabled by default) ability to lookup token by static password.
|
||||
|
||||
## [0.5.0] - 2026-01-17
|
||||
|
||||
### Added
|
||||
- Optional (disabled by default) ability to use a static password as backup auth.
|
||||
|
||||
### Changed
|
||||
- Nonce-related cleanup.
|
||||
|
||||
## [0.4.1] - 2025-12-26
|
||||
|
||||
### Fixed
|
||||
- Bug which can occur if cache files are deleted.
|
||||
|
||||
## [0.4.0] - 2025-12-26
|
||||
|
||||
### Changed
|
||||
- Massive rewrite to listener-based architecture instead of controllers.
|
||||
- Login payload sent via `X-Preauth` header instead of GET request parameters.
|
||||
- Enhanced cookie security.
|
||||
- Removed icon system and asset system.
|
||||
|
||||
## [0.3.0] - 2025-12-15
|
||||
|
||||
### Changed
|
||||
- **Breaking:** Default port and transport changed to HTTP on port 80.
|
||||
- **Breaking:** Environment variable names have changed.
|
||||
- Refactored to Symfony 7.4 with FrankenPHP.
|
||||
|
||||
## [0.2.0] - 2025-12-03
|
||||
|
||||
### Added
|
||||
- Login rate limiting (burst + upper window).
|
||||
- Error page for rate-limited clients ("too many requests").
|
||||
- Example Docker Compose file.
|
||||
|
||||
## [0.1.0] - 2025-11-14
|
||||
|
||||
### Added
|
||||
- Docker image published to Docker Hub.
|
||||
- PHP-FPM based, code in `src/`, templates in separate files.
|
||||
|
||||
## [0.0.1] - 2024-06-26
|
||||
|
||||
### Notes
|
||||
- Started as a single-file script in Caddy config. Hardcoded TOTP secret,
|
||||
zero flexibility, but functional. Ran quietly in production for about a
|
||||
year before any real development began.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Contributing to Preauth
|
||||
|
||||
Thank you for your interest in contributing to Preauth! This document
|
||||
outlines the process for contributing to the project.
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies: `composer install`
|
||||
3. Copy `.env.example` to `.env` and configure as needed
|
||||
4. Run tests: `vendor/bin/phpunit`
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
All code must pass the style check before it can be merged.
|
||||
|
||||
## Testing
|
||||
|
||||
All code changes must include tests. The project maintains 100% code
|
||||
coverage — new code must be fully tested.
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
vendor/bin/phpunit
|
||||
|
||||
# Run with coverage (requires Xdebug)
|
||||
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
- **Unit tests** go in `tests/Unit/` and mirror the `src/` directory structure
|
||||
- **Functional tests** go in `tests/Functional/` and test the full HTTP kernel
|
||||
- Use the support traits (`TotpTestHelper`, `ListenerTestHelper`) for
|
||||
reusable test fixtures
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a feature branch from `main`
|
||||
2. Make your changes, ensuring tests pass and code style is clean
|
||||
3. Update documentation if needed (README, CHANGELOG, docs/)
|
||||
4. Submit a pull request to `main`
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
- `feat:` new feature
|
||||
- `fix:` bug fix
|
||||
- `docs:` documentation only
|
||||
- `refactor:` code change that neither fixes a bug nor adds a feature
|
||||
- `test:` adding or correcting tests
|
||||
- `chore:` build process, tooling, etc.
|
||||
|
||||
## Architecture
|
||||
|
||||
Preauth is an event-listener-driven Symfony application (no controllers).
|
||||
See `ROADMAP.md` for the full architecture overview and design decisions.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under
|
||||
the MIT License.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Design Considerations — Preauth
|
||||
|
||||
## Summary
|
||||
|
||||
Preauth is a well-architected TOTP-based authentication gateway that has evolved from a single-file script into a clean, event-listener-driven Symfony application with 100% test coverage. The codebase demonstrates strong security fundamentals (host-prefixed cookies, nonce-based replay protection, rate limiting, backup code system) and thoughtful operational design (dual-layer cache with change tracking, FrankenPHP worker mode).
|
||||
|
||||
This document was originally prepared as a design review. Items that have been addressed are marked with ✅ and include a reference to the commit or change that resolved them. Items still open are marked with ⬜ and remain as recommendations for future work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security
|
||||
|
||||
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. A `SecurityHeadersListener` (response event, priority 0) now sets the following headers on all main-request responses:
|
||||
|
||||
```
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Strict-Transport-Security: max-age=31536000
|
||||
```
|
||||
|
||||
The inline `<script>` and `<style>` in the templates mean a CSP with `'unsafe-inline'` for `script-src` and `style-src` is the strictest practical policy today. Moving scripts/styles to external files would allow a stricter CSP in the future.
|
||||
|
||||
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The `Remote-User` header value is now configurable via the `REMOTE_USER` environment variable, which supports four modes:
|
||||
|
||||
- **`session`** (default, backward-compatible): Sends the session id, as before. The value is still sanitized via `makeCacheKey()`.
|
||||
- **`static`**: Sends a fixed string (configurable via `REMOTE_USER_STATIC`, default `authenticated`) for all authenticated requests. This eliminates the user-controlled header issue entirely.
|
||||
- **`mapped`**: Looks up the session id in a configured map (`REMOTE_USER_MAP`, format: `id1:user1,id2:user2`) and sends the mapped value. Falls back to the session id if not found in the map. This is the path to multi-user support.
|
||||
- **`none`**: Omits the `Remote-User` header entirely. Caddy's `forward_auth` still accepts the request based on the 200 status code.
|
||||
|
||||
The `RemoteUserMode` enum (`src/Enum/RemoteUserMode.php`) encapsulates the modes. `StringTrait::authSuccessResponse()` resolves the header value based on the configured mode, and `ConfigBag` handles parsing the map string and validating the mode (invalid values fall back to `session`). `AcceptListener` now receives `ConfigBag` as a constructor dependency to support this.
|
||||
|
||||
### 1.3 No CSRF Protection on POST Form Login [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Resolved through documentation and analysis. The nonce system provides CSRF protection for the POST form path: nonces are server-generated, single-use, and have a 120-second TTL. An attacker cannot forge a POST request without first loading the login page to obtain a valid nonce, which requires being on the auth subdomain. The `LoginListener` class docblock and `login.html.twig` template comment now explicitly document this CSRF protection model. The AJAX (header) path embeds the nonce in the base64url payload.
|
||||
|
||||
### 1.4 TOTP Verification Leeway May Be Too Generous [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The TOTP verification window has been reduced from 10 periods (±5 minutes) to 1 period (±30 seconds). With the default 30-second TOTP period, a code is now valid for at most 90 seconds (the current window plus one window on each side), down from the previous 50 seconds per window with 10-period leeway. The ROADMAP has been updated to reflect this change.
|
||||
|
||||
### 1.5 Backup Code Logging Reveals Code Value [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The debug log in `BackupCodeManager::verifyAndConsume()` no longer includes the backup key name. It now logs only the hit/miss and valid/invalid status: `"checking backup code: HIT & VALID"` or `"checking backup code: miss & invalid"`.
|
||||
|
||||
### 1.6 TOTP Object Reconstructed on Every Verification [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** `GetTotpTrait::getTotp()` calls `OTHP\Factory::loadFromProvisioningUri()` on every invocation. This parses the OTP URI string and constructs a new TOTP object each time a token is verified.
|
||||
|
||||
**Note:** An attempt was made to memoize the TOTP object within the request cycle, but PHP 8.4's `readonly` class constraint prevents traits from defining mutable properties in `readonly` classes (`LoginManager` and `BackupCodeManager` are both `final readonly`). Resolving this would require either removing `readonly` from these classes, using a separate memoization service, or refactoring `GetTotpTrait` into a dedicated injectable service.
|
||||
|
||||
**Why:** This is a minor performance concern — URI parsing and TOTP object construction happen on every login attempt. In a FrankenPHP worker process that handles many requests, this adds unnecessary overhead. It's not a security issue, but it's an easy optimization if the readonly constraint is relaxed.
|
||||
|
||||
### 1.7 CSS Injection in Style Template [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. Environment-configured color values (`bg_color`, `fg_color`, `error_color`) in `_style.html.twig` are now escaped with Twig's `|e('css')` filter to prevent CSS injection from malicious environment variable values.
|
||||
|
||||
### 1.8 $payload->json Access on Possibly-Null Payload [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. In `LoginListener::onKernelRequest()`, the `$payload->json` access on a possibly-null `$payload` has been replaced with `$payload?->json ?? true`, and `$payload->id` with `$payload?->id ?? ''`. This prevents a crash when a login attempt is detected (e.g., via the `X-Preauth` header) but the payload is invalid (malformed base64, non-object JSON, etc.).
|
||||
|
||||
### 1.9 validReturn() Doesn't Check false from parse_url [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. `DomainManager::validReturn()` now checks for `false` and empty string in addition to `null` when examining the return value of `parse_url($url, PHP_URL_HOST)`. This prevents a `TypeError` on malformed URLs that `filter_var(FILTER_VALIDATE_URL)` accepts but `parse_url` cannot parse.
|
||||
|
||||
### 1.10 Incomplete TLD List in DomainManager [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The TLD lookup table in `DomainManager` has been significantly expanded with many previously missing multi-part TLDs, including `.com.au`, `.co.jp`, `.com.br`, `.co.kr`, `.com.tw`, `.co.za`, and dozens more. Without these entries, domains like `evil.com.au` would incorrectly match `auth.example.com.au` (both would resolve to base `com.au`), creating an open redirect vulnerability. The host is also now lowercased before TLD lookup to fix a case-sensitivity issue.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & Code Quality
|
||||
|
||||
### 2.1 Trait-Based Dependency Injection Pattern [MEDIUM PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** Several traits (`HasLoggerTrait`, `GetTotpTrait`, `MakeNonceTrait`) use `#[Required]` attribute for setter injection into `readonly` classes. For example, `LoginManager` receives `$config`, `$logger`, and `$nonceCache` via traits rather than through its constructor. The constructor only accepts three parameters; the rest are wired via setter methods called by the service container after construction.
|
||||
|
||||
**Recommendation:** Move these dependencies into the constructors of the classes that use them. If multiple classes share the same dependencies, that's fine — PHP constructors can accept many parameters, and it makes the dependency graph explicit. Alternatively, create a shared `Dependencies` value object that bundles logger, config, and nonce cache.
|
||||
|
||||
**Why:** The trait-based setter injection pattern makes it non-obvious what dependencies a class has — you have to look at both the constructor and all the traits it uses. It also creates a temporal coupling issue: the object exists in a partially-constructed state between construction and setter calls. With `readonly` classes, this works only because the trait properties are declared in the trait, not the class, which is a subtle language detail that could confuse future maintainers. Standard constructor injection is more explicit, testable, and conventional in Symfony.
|
||||
|
||||
### 2.2 Duplicated Cookie Logic Between LoginManager and InterceptListener [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Resolved. The duplicated cookie name and domain selection logic has been extracted into two shared methods on `CookieNameTrait`:
|
||||
|
||||
- `sessionCookieName(DomainInterface $domainManager): string` — Returns the appropriate cookie name (`__Host-Http-Preauth` or `__Http-Domain-Preauth`) based on whether central auth is active.
|
||||
- `sessionCookieDomain(DomainInterface $domainManager, string $host): ?string` — Returns the cookie domain for central auth mode, or null for single-domain mode.
|
||||
|
||||
`LoginManager::setCookie()`, `AcceptListener::onKernelRequest()`, and `InterceptListener::pruneInvalidCookie()` all now use these shared methods. The fragile "changes here must be reflected in InterceptListener::pruneInvalidCookie()" comment has been removed.
|
||||
|
||||
### 2.3 MonitorCacheKeys Instantiated Multiple Times for Same Pool [MEDIUM PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** `MonitorCacheKeys` is a decorator that tracks cache key changes. It's instantiated independently in `PersistCache`, `LoginManager`, and `BackupCodeManager`, each wrapping the same underlying `CacheItemPoolInterface`. The key list (`__key_list`) and change list (`__chg_list`) are stored in the cache itself, so the instances share state — but each instance calls `initialize()` in its constructor if the lists don't exist yet, and each `save()`/`saveDeferred()` call triggers additional metadata writes.
|
||||
|
||||
**Recommendation:** Register `MonitorCacheKeys` as a decorated service in the DI container (using Symfony's `decorates` feature) so there's a single instance per cache pool. Or, make `MonitorCacheKeys` a stateless service that's injected once, rather than having each consumer create its own wrapper.
|
||||
|
||||
**Why:** Multiple instances wrapping the same pool is wasteful — each `save()` call triggers a cascade of metadata operations (update key list, log change, commit). With three instances, a single cache write could trigger nine additional cache operations. A single decorator service would be more efficient and would make the lifecycle clearer.
|
||||
|
||||
### 2.4 Payload Base64url Decoding Has Broken Padding [MEDIUM PRIORITY] ✅ Already Correct
|
||||
|
||||
**Current state:** Not an issue. The code correctly uses:
|
||||
```php
|
||||
$base64 = strtr($base64url, '-_', '+/');
|
||||
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
||||
```
|
||||
This was fixed in a prior commit ("Fix docs, add .dockerignore, fix base64url padding, fix typo"). The original review incorrectly reported the use of `str_pad`; the implementation now correctly uses `str_repeat` to add the proper number of `=` padding characters.
|
||||
|
||||
### 2.5 Symfony Sessions Enabled But Unused [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. `config/packages/framework.yaml` now has `session: false` with a comment explaining that preauth implements its own cookie/cache-based session management and does not use Symfony's session subsystem.
|
||||
|
||||
### 2.6 config/reference.php Committed to Repository [LOW PRIORITY] ✅ Already Handled
|
||||
|
||||
**Current state:** Not an issue. `config/reference.php` is already listed in `.gitignore` under the project-specific section and is not tracked in version control.
|
||||
|
||||
### 2.7 Public Properties on Payload DTO [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** `Payload` uses public properties (`$id`, `$token`, `$nonce`, `$json`, `$scope`) with no encapsulation. The object is mutable after construction.
|
||||
|
||||
**Recommendation:** Consider making `Payload` a `readonly` class (PHP 8.4+ supports `readonly` classes natively) with a constructor that takes all fields, or use Symfony's `Stringable`/value object patterns. Since `LoginManager` mutates `$payload->scope` (downgrading IP to Cookie), the current design requires mutability — but this could be handled by returning a new instance instead.
|
||||
|
||||
**Why:** Immutable DTOs are safer to pass around, especially in an event-driven system where the same object might be referenced by multiple listeners. The current mutation in `LoginManager::checkToken()` (changing `$payload->scope`) is a side effect that's not obvious from the method signature.
|
||||
|
||||
### 2.8 Duplicated "hi $id" Response Construction [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The duplicated `new Response("hi $id", headers: ['Content-Type' => 'text/plain', 'Remote-User' => $id])` pattern in `AcceptListener`, `AllowListener`, and `LoginManager` has been extracted into `StringTrait::authSuccessResponse(string $id): Response`, which all three classes now use.
|
||||
|
||||
### 2.9 Duplicated Constants [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The duplicated `'2999-12-31'` far-future date string (previously in `Utilities::makeTotp()` and `BackupCodeManager::verifyAndConsume()`/`saveCodes()`) and the `128` max input length (previously in `StringTrait::makeCacheKey()` and `Payload::create()`) have been extracted into `AppConstants::FAR_FUTURE_DATE` and `AppConstants::MAX_INPUT_LENGTH` respectively.
|
||||
|
||||
---
|
||||
|
||||
## 3. Testing
|
||||
|
||||
### 3.1 No Tests for Concurrent Access / Race Conditions [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** The test suite is excellent — 222 tests, 100% coverage, good edge case coverage. However, there are no tests for concurrent access scenarios, such as two requests using the same nonce simultaneously, or cache initialization race conditions in `MonitorCacheKeys`.
|
||||
|
||||
**Recommendation:** Add a few integration tests that simulate concurrent access (e.g., using process forks or mock caches with delays). At minimum, document that concurrent access is expected to be handled by APCu's atomic operations.
|
||||
|
||||
**Why:** `MonitorCacheKeys::initialize()` checks if key lists exist and creates them if not — under concurrent startup, two instances could both see missing lists and both call `initialize()`. This is likely fine because APCu operations are atomic, but it's worth having a test or at least a documented assumption. The race condition between `hasItem()` and `getItem()` in `AcceptListener` and `AllowListener` is now handled with an `isHit()` check, but is not tested.
|
||||
|
||||
### 3.2 No Security-Focused Test Suite [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** Security behaviors (nonce replay, backup code reuse, rate limiting) are tested as part of the functional and unit tests, but there's no dedicated security test suite that systematically probes for common vulnerabilities.
|
||||
|
||||
**Recommendation:** Consider adding a `tests/Security/` directory with tests for: XSS attempts in the username field, header injection via the `return` parameter, cookie attribute verification (Secure, HttpOnly, SameSite), and response header presence (now that security headers are added).
|
||||
|
||||
**Why:** For an authentication gateway, security testing deserves its own focused suite that's easy to find and extend. This also makes it easier for security reviewers to understand what's been tested.
|
||||
|
||||
---
|
||||
|
||||
## 4. Docker & Deployment
|
||||
|
||||
### 4.1 Healthcheck Depends on curl Which May Not Be Installed [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The Dockerfile now explicitly installs `curl` in the final image with `apt-get install -y --no-install-recommends curl` and cleans up the apt lists to keep the image small.
|
||||
|
||||
### 4.2 Typo in bin/franken.sh [LOW PRIORITY] ✅ Already Fixed / Addressed
|
||||
|
||||
**Current state:** Fixed. The typo (`digtialadapt` → `digitaladapt`) was corrected in a prior commit. The script has since been further improved: the hardcoded `APP_SECRET` has been removed (now uses the `APP_SECRET` environment variable or generates a random secret), and the `docker container rm` command now suppresses errors when the container doesn't exist.
|
||||
|
||||
### 4.3 No .dockerignore File [LOW PRIORITY] ✅ Already Handled
|
||||
|
||||
**Current state:** Not an issue. A `.dockerignore` file exists and excludes `.git/`, `.gitignore`, `var/`, `vendor/`, `tests/`, `.phpunit.cache/`, `docs/`, `*.md`, `.env`, `.env.test`, `.env.local`, and `composer.phar` from the Docker build context. This was added in a prior commit.
|
||||
|
||||
### 4.4 Dockerfile Uses PHP 8.5 Which Is Bleeding Edge [LOW PRIORITY] ⬜ Open (Deliberate)
|
||||
|
||||
**Current state:** The Dockerfile uses `php:8.5-trixie` for the build stage and `dunglas/frankenphp:php8.5-trixie` for the final image. `composer.json` requires `php >= 8.4`. The CI workflow in `tests.yaml` also uses PHP 8.5.
|
||||
|
||||
**Recommendation:** This is a deliberate choice and likely fine for a personal project. If broader compatibility is desired, consider testing against both PHP 8.4 and 8.5 in CI. The `composer.json` already allows 8.4+.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
### 5.1 Cache Exceptions Propagate as 500 Errors [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. Cache operations in `AcceptListener::onKernelRequest()` and `AllowListener::onKernelRequest()` are now wrapped in try/catch blocks that catch `Psr\Cache\InvalidArgumentException`. On a cache error, the listener logs the error at `error` level and returns without setting a response — causing the request to fall through to the next listener, which will eventually present the login page. This is a "fail closed" approach: if the cache is unavailable, the user is not authenticated.
|
||||
|
||||
**Note:** `LoginManager::checkToken()` and `BackupCodeManager::verifyAndConsume()` still declare `@throws InvalidArgumentException`. These are called from `LoginListener`, which does not catch the exception. A cache failure during login verification would still result in a 500 error. This is a lower-priority concern since login failures already result in a 401 response path.
|
||||
|
||||
### 5.2 No Global Exception Handling for Auth Flow [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** There is no `ExceptionListener` or `ErrorController` configured. Symfony's default error handling will produce a generic error page for uncaught exceptions. In dev mode (`APP_DEBUG=1`), this shows a full stack trace.
|
||||
|
||||
**Recommendation:** Add a simple exception listener that catches exceptions from the auth flow and returns a clean 401 or 503 response with the login page or error template. Alternatively, configure `framework.error_controller` to use a custom controller that renders the error template.
|
||||
|
||||
**Why:** For an auth gateway, every response should be intentional. A raw Symfony error page (even in production mode) doesn't match the styled login/error pages and could leak information about the internal architecture.
|
||||
|
||||
### 5.3 Kernel::terminate() Not Using try/finally [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. `Kernel::terminate()` now wraps `$this->persistCache->persist()` in a `try` block with a `finally` block that calls `parent::terminate()`. This ensures that the Symfony kernel termination always runs, even if the cache persistence throws an exception.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend
|
||||
|
||||
### 6.1 document.write() in Login Script [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The `document.open(); document.write(html); document.close();` pattern in `_script.html.twig` has been replaced with `document.documentElement.innerHTML = html;`. This avoids the deprecated `document.write()` call and is compatible with the Content-Security-Policy now set by `SecurityHeadersListener`.
|
||||
|
||||
### 6.2 No Input Sanitization in Username Echo [LOW PRIORITY] ⬜ Open
|
||||
|
||||
**Current state:** In `login.html.twig`, the username is echoed back into the input value: `value="{{ username }}"`. The username comes from the sanitized `makeCacheKey()` output, which restricts to `[A-Za-z0-9_.]`, so HTML injection is not possible with the current sanitization. Twig's auto-escaping is also on by default.
|
||||
|
||||
**Recommendation:** Add Twig's `escape` filter explicitly for defense-in-depth: `value="{{ username|e('html_attr') }}"`. Also consider whether the `message` variable in `<p id="preauth-message">{{ message|default }}</p>` could ever contain user input.
|
||||
|
||||
**Why:** While the current sanitization prevents XSS, relying on `makeCacheKey()` for HTML safety is an implicit coupling between cache key logic and output safety. If `makeCacheKey()` were ever relaxed to allow more characters, the template would become vulnerable. Twig auto-escaping handles HTML body context, but `html_attr` escaping is more appropriate for attribute contexts.
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration
|
||||
|
||||
### 7.1 No Validation of Environment Variables [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. Environment variables in `config/services.yaml` now use Symfony's env var processors for type casting:
|
||||
|
||||
- `app.cookie_ttl: '%env(int:COOKIE_TTL)%'`
|
||||
- `app.subdomain_redirect: '%env(bool:SUBDOMAIN_REDIRECT)%'`
|
||||
- `app.ip_ttl: '%env(int:IP_TTL)%'`
|
||||
- `app.teapot: '%env(bool:TEAPOT)%'`
|
||||
|
||||
This ensures invalid values fail fast at container compilation rather than at runtime with a confusing type error. The `rate_limiter.yaml` already used `%env(int:...)%` — this pattern is now applied consistently.
|
||||
|
||||
### 7.2 APP_SECRET Not Used Meaningfully [LOW PRIORITY] ✅ Addressed (Documented)
|
||||
|
||||
**Current state:** `APP_SECRET` is configured in `framework.yaml` and is required by Symfony. Preauth doesn't use Symfony sessions (now explicitly disabled), CSRF tokens, or signed cookies — the main uses of `APP_SECRET`. The README now documents that `APP_SECRET` is a Symfony requirement and that session cookies are random ULIDs looked up in cache, not signed tokens. The hardcoded `APP_SECRET` in `bin/franken.sh` has also been removed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
### 8.1 Missing Security Model Documentation [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Addressed. The README now includes a comprehensive "Security Model" section under "Architecture" that covers:
|
||||
|
||||
- Cookie security attributes (`__Host-` prefix, `SameSite=Strict`, `Secure`, `HttpOnly`)
|
||||
- Nonce system (15-byte random, single-use, 120s TTL)
|
||||
- TOTP verification window (±1 period / ±30 seconds)
|
||||
- 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)
|
||||
|
||||
A dedicated `docs/SECURITY.md` with the full threat model and `Remote-User` guidance (see item 1.2) could still be valuable as a standalone document.
|
||||
|
||||
### 8.2 Missing CHANGELOG.md [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. A `CHANGELOG.md` has been created following the [Keep a Changelog](https://keepachangelog.com/) format, with full version history from v0.0.1 through the unreleased v1.0 changes. The version history was previously inline in the README.
|
||||
|
||||
### 8.3 Missing CONTRIBUTING.md [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. A `CONTRIBUTING.md` has been created with development setup instructions, code style guidelines, testing requirements, PR process, commit message conventions, and architecture overview.
|
||||
|
||||
### 8.4 Stale Branch References in ROADMAP [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The ROADMAP's branch status table has been updated to reflect that all feature branches have been pruned and development uses a feature-branch + PR workflow into `main`. Completed security review items are now checked off, and the TOTP leeway description has been updated from "10-second leeway" to "±1 period leeway (±30 seconds)".
|
||||
|
||||
---
|
||||
|
||||
## 9. CI & Workflows
|
||||
|
||||
### 9.1 Inconsistent Tag Format [MEDIUM PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. Git tags are now standardized on the `v` prefix (e.g., `v1.0.0` instead of `1.0.0`). The Docker workflow (`docker.yaml`) now triggers on `v*.*.*` tag patterns and includes a step to extract the version number without the `v` prefix for the Docker image tag. The existing un-prefixed tags (`0.7.0` through `0.10.0`) remain in the repository but all future releases will use the `v` prefix.
|
||||
|
||||
### 9.2 Stale develop Branch in CI Triggers [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The `tests.yaml` and `develop.yaml` workflows no longer reference the `develop` branch, which has been pruned. CI now triggers on `main` only (for push) and `main` only (for pull requests).
|
||||
|
||||
### 9.3 publish.yaml Fails on Re-run [LOW PRIORITY] ✅ Addressed
|
||||
|
||||
**Current state:** Fixed. The GitHub sync workflow (`publish.yaml`) now uses `git remote add ... 2>/dev/null || git remote set-url ...` instead of bare `git remote add`, which would fail if the remote already existed from a previous run.
|
||||
|
||||
---
|
||||
|
||||
## What's Done Well
|
||||
|
||||
- **Listener-based architecture** is a good fit for this use case — each listener has a single responsibility, and the priority chain creates a clear request processing pipeline.
|
||||
- **Cookie security** is excellent: `__Host-` prefix, `Secure`, `HttpOnly`, `SameSite=Strict`, and a separate non-prefixed cookie for domain-scoped central auth. Cookie name and domain selection logic is now shared via `CookieNameTrait::sessionCookieName()` and `sessionCookieDomain()`.
|
||||
- **Nonce-based replay protection** with single-use, TTL-limited nonces and collision retry is well-designed. The nonce also serves as CSRF protection for the POST form path.
|
||||
- **Rate limiting** with compound sliding windows (burst + sustained) and the humorous teapot option is practical and well-implemented.
|
||||
- **Test suite** is exemplary: 100% coverage, good use of test helpers, functional tests that exercise the full kernel, and edge cases like ULID collisions and nonce reuse.
|
||||
- **Dual-layer cache** (APCu + filesystem with change tracking) is a clever solution for persistence without a database.
|
||||
- **Backup code system** with single-use enforcement, case-insensitivity, and audit trail (keeping consumed codes with `false` value) is well thought out. Backup code values are no longer logged.
|
||||
- **Interfaces** (`LoginInterface`, `DomainInterface`, `BackupCodeInterface`) enable clean mocking in tests. All now have `declare(strict_types=1)`.
|
||||
- **FrankenPHP worker mode** via the Caddyfile and Dockerfile is a modern, performant serving strategy.
|
||||
- **Security headers** are now set on all responses via `SecurityHeadersListener`.
|
||||
- **Error handling** in cache-dependent listeners now fails closed (denies access on cache errors) rather than propagating 500 errors.
|
||||
|
||||
---
|
||||
|
||||
*Originally prepared as a design review. Updated to reflect the state of the `fix/v1.0-must-fix` branch.*
|
||||
+4
-1
@@ -33,9 +33,12 @@ RUN composer dump-env prod --empty
|
||||
# start creating final image
|
||||
FROM dunglas/frankenphp:php8.5-trixie
|
||||
|
||||
# install APCu
|
||||
# install APCu and curl (needed for healthcheck)
|
||||
RUN pecl install apcu && \
|
||||
docker-php-ext-enable apcu
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# symfony required environment variables
|
||||
ENV APP_DEBUG=0
|
||||
|
||||
+49
-61
@@ -38,12 +38,16 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
||||
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
|
||||
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`).
|
||||
4. **LoginListener** (priority 66) — Detects login attempts via
|
||||
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`.
|
||||
5. **InterceptListener** (priority 55) — Fallback: if no listener has
|
||||
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.
|
||||
|
||||
@@ -60,7 +64,7 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
||||
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.
|
||||
- **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
|
||||
@@ -76,8 +80,8 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
||||
|
||||
| Metric | Value |
|
||||
|--------------|--------------------------------|
|
||||
| **Tests** | 222 |
|
||||
| **Assertions** | 469 |
|
||||
| **Tests** | 293 |
|
||||
| **Assertions** | 605 |
|
||||
| **Pass** | 222 (100%) |
|
||||
| **Fail** | 0 |
|
||||
| **Errors** | 0 |
|
||||
@@ -109,12 +113,14 @@ Every class, method, and line in `src/` is covered.
|
||||
| `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 |
|
||||
@@ -122,6 +128,7 @@ Every class, method, and line in `src/` is covered.
|
||||
| `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
|
||||
|
||||
@@ -157,7 +164,7 @@ Every class, method, and line in `src/` is covered.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1 — Public but Rate-Limited Access ✦
|
||||
### 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
|
||||
@@ -169,53 +176,39 @@ bandwidth, forcing it back to fully private. The solution isn't more
|
||||
authentication — it's bandwidth/resource protection for public-facing
|
||||
services.
|
||||
|
||||
**Design:**
|
||||
**Implementation:**
|
||||
|
||||
- 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
|
||||
- `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: **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.
|
||||
- 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).
|
||||
|
||||
- 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
|
||||
}
|
||||
```
|
||||
- New service: **PublicPathMatcher** — Parses path patterns and matches
|
||||
request paths with wildcard support.
|
||||
|
||||
- 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).
|
||||
- Separate `public_limiter` compound rate limiter (independent from
|
||||
the login attempt rate limiter).
|
||||
|
||||
- [ ] 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
|
||||
- [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
|
||||
|
||||
@@ -380,15 +373,14 @@ approach proves insufficient for the use case.*
|
||||
- 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
|
||||
- ✅ 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)
|
||||
- ✅ Changelog formalised (CHANGELOG.md)
|
||||
- ✅ CI workflows (tests + php-cs-fixer on push/PR, 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
|
||||
- ✅ 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:**
|
||||
@@ -476,13 +468,9 @@ identity provider:
|
||||
|
||||
| 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. |
|
||||
| `main` (0.10.0) | Production | Current stable release |
|
||||
|
||||
All feature branches have been pruned. Development uses a feature-branch + PR workflow into `main`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Dev utility — builds and runs the preauth container locally.
|
||||
# Not for production use.
|
||||
# APP_SECRET should be set in your environment or .env file.
|
||||
|
||||
docker container rm preauth
|
||||
docker container rm preauth 2>/dev/null
|
||||
docker build . -t digitaladapt/preauth:dev
|
||||
docker run --name preauth \
|
||||
-e APP_ENV=dev \
|
||||
-e APP_DEBUG=true \
|
||||
-e APP_SECRET=f88a1074691c40415be4439345b79f69 \
|
||||
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
|
||||
-e APP_SHARE_DIR=var/share \
|
||||
-e DEFAULT_URI=http://localhost \
|
||||
-v ./var/share:/app/var/share \
|
||||
|
||||
@@ -10,6 +10,8 @@ framework:
|
||||
adapters: cache.adapter.apcu
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.filesystem
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
prefix_seed: digitaladapt/preauth
|
||||
|
||||
@@ -5,5 +5,6 @@ framework:
|
||||
trusted_proxies: 'private_ranges'
|
||||
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto']
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
# Sessions are disabled — preauth implements its own cookie/cache-based
|
||||
# session management and does not use Symfony's session subsystem.
|
||||
session: false
|
||||
|
||||
@@ -13,3 +13,17 @@ framework:
|
||||
login_limiter:
|
||||
policy: compound
|
||||
limiters: [burst, upper]
|
||||
|
||||
public_burst:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:PUBLIC_BURST_COUNT)%'
|
||||
interval: '%env(int:PUBLIC_BURST_TIME)% seconds'
|
||||
cache_pool: 'publicRateLimitCache'
|
||||
public_upper:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:PUBLIC_UPPER_COUNT)%'
|
||||
interval: '%env(int:PUBLIC_UPPER_TIME)% seconds'
|
||||
cache_pool: 'publicRateLimitCache'
|
||||
public_limiter:
|
||||
policy: compound
|
||||
limiters: [public_burst, public_upper]
|
||||
|
||||
@@ -10,3 +10,5 @@ framework:
|
||||
adapters: cache.adapter.array
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.array
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
|
||||
+34
-4
@@ -27,6 +27,16 @@ parameters:
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
env(TEAPOT): '1' # boolean
|
||||
|
||||
# --- remote-user header ---
|
||||
# Controls the value sent in the Remote-User header on successful auth.
|
||||
# session: the session id (default, backward-compatible)
|
||||
# static: a fixed string (set via REMOTE_USER_STATIC)
|
||||
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
|
||||
# none: do not send the Remote-User header at all
|
||||
env(REMOTE_USER): 'session'
|
||||
env(REMOTE_USER_STATIC): 'authenticated'
|
||||
env(REMOTE_USER_MAP): ''
|
||||
|
||||
# --- rate limiting ---
|
||||
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
||||
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
|
||||
@@ -35,6 +45,16 @@ parameters:
|
||||
env(UPPER_COUNT): 10 # 10 per hour
|
||||
env(UPPER_TIME): 3600 # seconds (1 hour)
|
||||
|
||||
# --- public access (rate-limited, no auth required) ---
|
||||
# Comma-separated path patterns for public access. Wildcards: * (single
|
||||
# segment), ** (cross segments). Optional host prefix: host.com/path/**
|
||||
# When empty (default), the feature is fully disabled.
|
||||
env(PUBLIC_PATHS): ''
|
||||
env(PUBLIC_BURST_COUNT): 100 # max requests per burst window per IP
|
||||
env(PUBLIC_BURST_TIME): 60 # burst window in seconds
|
||||
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
|
||||
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
|
||||
|
||||
# --- styling options ---
|
||||
env(TITLE): 'Pre-Authentication System'
|
||||
env(BG_COLOR): '#029386' # teal
|
||||
@@ -56,12 +76,22 @@ parameters:
|
||||
|
||||
# --- application variables ---
|
||||
app.totp_uri: '%env(TOTP_URI)%'
|
||||
app.cookie_ttl: '%env(COOKIE_TTL)%'
|
||||
app.subdomain_redirect: '%env(SUBDOMAIN_REDIRECT)%'
|
||||
app.cookie_ttl: '%env(int:COOKIE_TTL)%'
|
||||
app.subdomain_redirect: '%env(bool:SUBDOMAIN_REDIRECT)%'
|
||||
app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%'
|
||||
|
||||
app.ip_ttl: '%env(IP_TTL)%'
|
||||
app.teapot: '%env(TEAPOT)%'
|
||||
app.ip_ttl: '%env(int:IP_TTL)%'
|
||||
app.teapot: '%env(bool:TEAPOT)%'
|
||||
|
||||
app.remote_user: '%env(REMOTE_USER)%'
|
||||
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
|
||||
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
|
||||
|
||||
app.public_paths: '%env(PUBLIC_PATHS)%'
|
||||
app.public_burst_count: '%env(int:PUBLIC_BURST_COUNT)%'
|
||||
app.public_burst_time: '%env(int:PUBLIC_BURST_TIME)%'
|
||||
app.public_upper_count: '%env(int:PUBLIC_UPPER_COUNT)%'
|
||||
app.public_upper_time: '%env(int:PUBLIC_UPPER_TIME)%'
|
||||
|
||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
|
||||
+23
-1
@@ -20,9 +20,31 @@ protected.example.com {
|
||||
reverse_proxy protected-service:9000
|
||||
}
|
||||
|
||||
# optionally, if you want to use a subdomain for centeral preauth
|
||||
# optionally, if you want to use a subdomain for central preauth
|
||||
# set SUBDOMAIN_REDIRECT to true
|
||||
# and AUTH_SUBDOMAIN to match the subdomain you use here
|
||||
auth.example.com {
|
||||
reverse_proxy preauth
|
||||
}
|
||||
|
||||
# --- public rate-limited access (v1.1) ---
|
||||
# Configure PUBLIC_PATHS env var to specify which paths are public.
|
||||
# Example: PUBLIC_PATHS=/public/**
|
||||
# Unauthenticated visitors to public paths are rate-limited separately
|
||||
# from login attempts. Authenticated users bypass the public rate limiter.
|
||||
#
|
||||
# This example protects all of Gitea except /public/** which is
|
||||
# publicly accessible but rate-limited (e.g., 100 req/min, 500 req/hr).
|
||||
git.example.com {
|
||||
forward_auth preauth {
|
||||
uri {uri}
|
||||
copy_headers Remote-User
|
||||
}
|
||||
reverse_proxy gitea:3000
|
||||
}
|
||||
# In preauth's .env:
|
||||
# PUBLIC_PATHS=/public/**
|
||||
# PUBLIC_BURST_COUNT=100
|
||||
# PUBLIC_BURST_TIME=60
|
||||
# PUBLIC_UPPER_COUNT=500
|
||||
# PUBLIC_UPPER_TIME=3600
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# TODO rename "example.env" to ".env", edit as needed
|
||||
# rename "example.env" to ".env", edit as needed
|
||||
# strongly recommend setting TOTP_URI, if not provided the app
|
||||
# will generate one for you, please copy it into your .env file
|
||||
- .env
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
#TEAPOT=true # default enabled, boolean
|
||||
|
||||
# --- remote-user header ---
|
||||
# Controls the value sent in the Remote-User header on successful auth.
|
||||
# session: the session id (default, backward-compatible)
|
||||
# static: a fixed string (set via REMOTE_USER_STATIC)
|
||||
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
|
||||
# none: do not send the Remote-User header at all
|
||||
#REMOTE_USER=session
|
||||
#REMOTE_USER_STATIC=authenticated
|
||||
#REMOTE_USER_MAP=''
|
||||
|
||||
# --- rate limiting ---
|
||||
|
||||
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
|
||||
@@ -33,6 +43,18 @@
|
||||
#UPPER_COUNT=10 # 10 per hour
|
||||
#UPPER_TIME=3600 # seconds (1 hour)
|
||||
|
||||
# --- public access (rate-limited, no auth required) ---
|
||||
# Comma-separated path patterns for public access. Wildcards:
|
||||
# * matches any chars within one path segment (not crossing /)
|
||||
# ** matches any chars including / (crosses path segments)
|
||||
# Optional host prefix: host.example.com/path/**
|
||||
# When empty (default), the feature is fully disabled.
|
||||
#PUBLIC_PATHS=''
|
||||
#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)
|
||||
|
||||
# --- styling options ---
|
||||
|
||||
#TITLE='Pre-Authentication System'
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
# v1.1 Plan — Public Rate-Limited Access
|
||||
|
||||
## Goal
|
||||
|
||||
Allow preauth to provide rate-limited unauthenticated access to select
|
||||
public paths. Authenticated users bypass the public rate limiter entirely.
|
||||
Non-public paths continue to trigger the existing auth flow.
|
||||
|
||||
**Practical example:** Allow anyone to visit
|
||||
`https://code.devgnome.com/public/*` in Gitea, but limit them to 100
|
||||
requests/minute and 500 requests/hour per IP.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When a request arrives and the user is **not authenticated** (no valid
|
||||
cookie or IP session), the new `PublicAccessListener` checks whether the
|
||||
request path matches any configured public path pattern. If it does:
|
||||
|
||||
1. The public rate limiter is consulted (separate from the login limiter).
|
||||
2. If within limits → `200 OK` (no `Remote-User` header). Caddy proxies
|
||||
to the backend.
|
||||
3. If over limits → `429 Too Many Requests` with a `Retry-After` header.
|
||||
|
||||
If the path does **not** match any public pattern, the request falls
|
||||
through to the existing auth flow (RejectListener → LoginListener →
|
||||
InterceptListener → login page or redirect).
|
||||
|
||||
**Authenticated users** never reach the `PublicAccessListener` because
|
||||
`AcceptListener` (priority 99) or `AllowListener` (priority 88) will have
|
||||
already set a `200` response before `PublicAccessListener` runs.
|
||||
|
||||
### Listener Priority Chain (updated)
|
||||
|
||||
```
|
||||
Priority Listener Action
|
||||
──────── ───────────────── ──────────────────────────────────────
|
||||
99 AcceptListener Valid cookie → 200 OK
|
||||
88 AllowListener Valid IP session → 200 OK
|
||||
84 PublicAccessListener Public path + rate limit check → 200 or 429
|
||||
77 RejectListener Login rate-limit gate → 418/429
|
||||
66 LoginListener Login attempt handling
|
||||
55 InterceptListener Fallback → redirect or login page
|
||||
```
|
||||
|
||||
`PublicAccessListener` runs at priority 84 — after auth checks (so
|
||||
authenticated users bypass it) but before `RejectListener` (so public
|
||||
access is not subject to the login rate limiter).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. Wildcard `*` supported. |
|
||||
| `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) |
|
||||
|
||||
**When `PUBLIC_PATHS` is empty (default), the feature is completely
|
||||
disabled and has zero effect on existing behavior.**
|
||||
|
||||
### Path Pattern Syntax
|
||||
|
||||
- Patterns are matched against the request **path** only (query string
|
||||
is ignored).
|
||||
- Patterns must start with `/`.
|
||||
- `*` matches any sequence of characters within a single path segment
|
||||
(not crossing `/`).
|
||||
- `**` matches any sequence of characters including `/` (crosses path
|
||||
segments).
|
||||
- No other regex or special characters are supported — patterns are
|
||||
literal strings with `*` wildcards.
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Pattern | Matches | Does NOT match |
|
||||
|---------|---------|----------------|
|
||||
| `/public` | `/public` | `/public/`, `/public/xyz` |
|
||||
| `/public/*` | `/public/anything`, `/public/xyz` | `/public`, `/public/a/b` |
|
||||
| `/public/**` | `/public/anything`, `/public/a/b/c` | `/public` |
|
||||
| `/public` | `/public` | `/public/xyz` |
|
||||
| `/api/*/status` | `/api/v1/status`, `/api/v2/status` | `/api/v1/v2/status` |
|
||||
|
||||
### Domain-Scoped Paths (when using auth subdomain)
|
||||
|
||||
When `SUBDOMAIN_REDIRECT=true` and `AUTH_SUBDOMAIN` is set, the user may
|
||||
want public paths on specific subdomains only. In this case, `PUBLIC_PATHS`
|
||||
can optionally include a domain prefix:
|
||||
|
||||
```
|
||||
PUBLIC_PATHS='code.devgnome.com/public/**,auth.devgnome.com/health'
|
||||
```
|
||||
|
||||
When no domain prefix is given, the path matches on **any** host. When a
|
||||
domain prefix is given, it only matches on that specific host.
|
||||
|
||||
When **not** using an auth subdomain (the common case), paths without a
|
||||
domain prefix match on all hosts. Domain-prefixed entries can still be
|
||||
used to restrict to specific hosts.
|
||||
|
||||
### Rate Limiter
|
||||
|
||||
A new `public_limiter` compound rate limiter is added to
|
||||
`rate_limiter.yaml`, following the same pattern as the existing
|
||||
`login_limiter`. It uses a `publicRateLimitCache` pool (APCu in
|
||||
production, array adapter in tests).
|
||||
|
||||
---
|
||||
|
||||
## New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/Service/PublicPathMatcher.php` | Service that parses `PUBLIC_PATHS` and matches request paths against patterns |
|
||||
| `src/Service/PublicPathMatcherInterface.php` | Interface for testability |
|
||||
| `src/Listener/PublicAccessListener.php` | Listener that checks public paths and applies rate limiting |
|
||||
|
||||
## Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `config/services.yaml` | Add `PUBLIC_PATHS` and related env vars + parameters |
|
||||
| `config/packages/rate_limiter.yaml` | Add `public_burst`, `public_upper`, `public_limiter` |
|
||||
| `config/packages/cache.yaml` | Add `publicRateLimitCache` pool |
|
||||
| `config/packages/test/cache.yaml` | Add `publicRateLimitCache` pool (array adapter) |
|
||||
| `src/ConfigBag.php` | Add `publicPaths()` method returning parsed path patterns |
|
||||
| `tests/TestKernel.php` | Add `publicRateLimitCache` to reset exclusion list |
|
||||
| `tests/Support/ListenerTestHelper.php` | Add helper for public rate limiter factory |
|
||||
| `docs/example.env` | Document new env vars |
|
||||
| `.env.test` | Add test defaults for public paths vars |
|
||||
| `.env` | Add dev defaults for public paths vars |
|
||||
| `docs/Caddyfile` | Add example of public + protected service config |
|
||||
| `CHANGELOG.md` | Add v1.1 section |
|
||||
| `ROADMAP.md` | Mark Phase 1 as in-progress / completed |
|
||||
| `readme.md` | Document public access feature |
|
||||
|
||||
## New Test Files
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `tests/Unit/Service/PublicPathMatcherTest.php` | Pattern parsing, matching, wildcards, domain scoping |
|
||||
| `tests/Unit/Listener/PublicAccessListenerTest.php` | Listener logic: public path match → 200, non-public → pass through, rate limited → 429, authenticated → not reached |
|
||||
| `tests/Functional/PublicAccessFlowTest.php` | End-to-end: public path accessible, rate limit enforced, non-public path shows login, authenticated user bypasses public rate limit |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **`PublicPathMatcher`** — Pure path matching logic, no dependencies.
|
||||
Parse the `PUBLIC_PATHS` string into pattern entries (each with
|
||||
optional host + path pattern). Convert `*`/`**` wildcards to regex.
|
||||
Match a given (host, path) against all patterns.
|
||||
|
||||
2. **Config** — Add env vars to `services.yaml`, add rate limiter to
|
||||
`rate_limiter.yaml`, add cache pool to `cache.yaml` + test cache.
|
||||
|
||||
3. **`ConfigBag`** — Add `publicPaths()` returning the raw string (the
|
||||
`PublicPathMatcher` does the parsing). Or add the `PublicPathMatcher`
|
||||
as a service that receives the raw string via autowiring.
|
||||
|
||||
4. **`PublicAccessListener`** — Inject `PublicPathMatcherInterface`,
|
||||
`RateLimiterFactoryInterface` (target `public_limiter`), and
|
||||
`ConfigBag`. On `RequestEvent`:
|
||||
- If no public paths configured → return immediately.
|
||||
- If request already has a response → return (auth listeners ran first).
|
||||
- Check if (host, path) matches any public pattern.
|
||||
- If no match → return (fall through to auth flow).
|
||||
- If match → consume(1) from public rate limiter.
|
||||
- If over limit → set 429 response with `Retry-After`.
|
||||
- If within limit → set 200 response (plain text, no `Remote-User`).
|
||||
|
||||
5. **Tests** — Unit tests for `PublicPathMatcher` and
|
||||
`PublicAccessListener`, functional tests for the full flow.
|
||||
|
||||
6. **Documentation** — Update all docs.
|
||||
|
||||
7. **Lint + Test** — Run php-cs-fixer + phpunit, fix any issues.
|
||||
|
||||
8. **Commit + Push + PR.**
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Why priority 84?
|
||||
|
||||
- Must be **after** `AcceptListener` (99) and `AllowListener` (88) so
|
||||
authenticated users never hit the public rate limiter.
|
||||
- Must be **before** `RejectListener` (77) so public access is not
|
||||
blocked by the login attempt rate limiter.
|
||||
- Must be **before** `LoginListener` (66) so login attempts on public
|
||||
paths are still processed (though this is an edge case — a login
|
||||
attempt on a public path would set a response in `PublicAccessListener`
|
||||
before `LoginListener` runs, which is correct: you don't need to login
|
||||
to access a public path).
|
||||
|
||||
**Wait — actually this is a problem.** If someone sends an `X-Preauth`
|
||||
header on a public path, `PublicAccessListener` would return 200 before
|
||||
`LoginListener` can process the login. But that's actually fine — if the
|
||||
path is public, they don't need to log in. If they want to authenticate,
|
||||
they can visit a non-public path.
|
||||
|
||||
**Revised approach:** `PublicAccessListener` should only return 200 for
|
||||
**GET/HEAD** requests to public paths, or all methods? For a gate like
|
||||
this, all methods should be allowed on public paths — the backend
|
||||
service (e.g., Gitea) handles its own authorization for write
|
||||
operations.
|
||||
|
||||
### Why a separate rate limiter?
|
||||
|
||||
The existing `login_limiter` rate limits **login attempts** (failures).
|
||||
The public rate limiter rate limits **all requests** to public paths.
|
||||
They serve different purposes and need independent counters. Using the
|
||||
same limiter would mean public traffic could exhaust the login attempt
|
||||
budget, or vice versa.
|
||||
|
||||
### Why `Retry-After` header?
|
||||
|
||||
It's a standard HTTP header (RFC 7231) that tells clients how long to
|
||||
wait before retrying. Legitimate clients (browsers, API consumers) and
|
||||
crawlers respect it.
|
||||
|
||||
### Why no `Remote-User` header on public responses?
|
||||
|
||||
The `Remote-User` header tells the backend who the authenticated user
|
||||
is. For public access, there is no authenticated user. Sending
|
||||
`Remote-User: public` or similar could confuse the backend. The backend
|
||||
should treat requests without `Remote-User` as anonymous.
|
||||
|
||||
### Path matching: query strings
|
||||
|
||||
Query strings are **ignored** for path matching. `/public?foo=bar`
|
||||
matches the pattern `/public`. This is implemented by using
|
||||
`$request->getPathInfo()` which returns the path without query string.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Empty `PUBLIC_PATHS`** → Feature disabled, zero impact on existing
|
||||
behavior. All tests pass unchanged.
|
||||
|
||||
2. **Authenticated user visits a public path** → `AcceptListener` or
|
||||
`AllowListener` returns 200 before `PublicAccessListener` runs. The
|
||||
public rate limiter is never consulted.
|
||||
|
||||
3. **Public path rate limit exceeded** → 429 with `Retry-After` header.
|
||||
The response uses the error template (same as login rate limit) but
|
||||
always with 429 status (never teapot — teapot is for login failures).
|
||||
|
||||
4. **Non-public path on a host that has some public paths** → Falls
|
||||
through to the normal auth flow. Login page or redirect.
|
||||
|
||||
5. **`PUBLIC_PATHS` with whitespace** → Trimmed during parsing.
|
||||
`PUBLIC_PATHS='/public, /api'` is equivalent to `/public,/api`.
|
||||
|
||||
6. **Invalid patterns** (not starting with `/`) → Silently ignored
|
||||
during parsing. Logged at debug level.
|
||||
|
||||
7. **Login attempt on a public path** → `PublicAccessListener` returns
|
||||
200 before `LoginListener` runs. This is correct behavior — if the
|
||||
path is public, no login is needed.
|
||||
|
||||
8. **Subdomain redirect mode + public paths** → If using an auth
|
||||
subdomain, requests to the auth subdomain itself should never be
|
||||
treated as public. The `PublicAccessListener` should skip requests
|
||||
where `host === authSubdomain`.
|
||||
|
||||
---
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### Unit Tests — `PublicPathMatcherTest`
|
||||
|
||||
- Empty string → no patterns → matches nothing
|
||||
- Single path `/public` → matches exact, not `/public/`
|
||||
- Wildcard `/public/*` → matches `/public/x`, not `/public`, not `/public/a/b`
|
||||
- Double wildcard `/public/**` → matches `/public/a/b/c`
|
||||
- Multiple patterns comma-separated
|
||||
- Domain-prefixed pattern `host.example.com/public/**`
|
||||
- Path without domain prefix matches any host
|
||||
- Whitespace trimming
|
||||
- Invalid patterns (no leading `/`) ignored
|
||||
- Case sensitivity (paths are case-sensitive, hosts are case-insensitive)
|
||||
|
||||
### Unit Tests — `PublicAccessListenerTest`
|
||||
|
||||
- No public paths configured → returns without setting response
|
||||
- Non-public path → returns without setting response
|
||||
- Public path, within rate limit → sets 200 response
|
||||
- Public path, rate limit exceeded → sets 429 response with Retry-After
|
||||
- Public path, response already set by earlier listener → returns
|
||||
- Auth subdomain request → skipped (even if path matches)
|
||||
- Uses `ListenerTestHelper` for mock rate limiters and collaborators
|
||||
|
||||
### Functional Tests — `PublicAccessFlowTest`
|
||||
|
||||
- Public path accessible without authentication → 200
|
||||
- Non-public path without auth → 401 (login page)
|
||||
- Rate limit enforcement: multiple requests exceed burst → 429
|
||||
- Authenticated user visits public path → 200 with Remote-User (bypasses public limiter)
|
||||
- 429 response includes Retry-After header
|
||||
- Query string ignored for path matching
|
||||
- Wildcard matching works end-to-end
|
||||
|
||||
---
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
### README
|
||||
|
||||
New section: **"Public Rate-Limited Access"** under Configuration.
|
||||
|
||||
- Explain the feature and use case
|
||||
- Document all env vars
|
||||
- Show path pattern syntax with examples
|
||||
- Show Caddyfile configuration for public + protected services
|
||||
- Note that authenticated users bypass the public rate limiter
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
New `[Unreleased]` → v1.1 section with all new features.
|
||||
|
||||
### ROADMAP
|
||||
|
||||
Mark Phase 1 items as completed.
|
||||
|
||||
### docs/example.env
|
||||
|
||||
Add all new env vars with comments.
|
||||
|
||||
### docs/Caddyfile
|
||||
|
||||
Add example showing a service with both public and protected paths.
|
||||
@@ -22,6 +22,12 @@
|
||||
<!-- high rate limits so functional tests don't get blocked -->
|
||||
<server name="BURST_COUNT" value="10000" />
|
||||
<server name="UPPER_COUNT" value="10000" />
|
||||
<!-- public access: enable for functional tests with low limits -->
|
||||
<server name="PUBLIC_PATHS" value="/public/**" />
|
||||
<server name="PUBLIC_BURST_COUNT" value="3" />
|
||||
<server name="PUBLIC_BURST_TIME" value="60" />
|
||||
<server name="PUBLIC_UPPER_COUNT" value="10000" />
|
||||
<server name="PUBLIC_UPPER_TIME" value="3600" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
|
||||
@@ -1,17 +1,257 @@
|
||||
# Preauth
|
||||
For when you want to expose a web service without letting the whole world try to access it. Because sometimes you want both a belt and suspenders.
|
||||
|
||||
I found myself needing to make my personal Nextcloud instance available outside my VPN, but was worried since it has had authentication exploits in the past.
|
||||
A lightweight TOTP authentication gateway for self-hosted web services.
|
||||
|
||||
So, I built a simple authentication gateway, which eventually turned into this project.
|
||||
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.
|
||||
|
||||
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
|
||||
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.
|
||||
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
|
||||
@@ -27,62 +267,23 @@ vendor/bin/php-cs-fixer fix
|
||||
vendor/bin/phpunit
|
||||
```
|
||||
|
||||
## Requirements
|
||||
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.
|
||||
|
||||
* Docker
|
||||
* Caddy (as a reverse proxy)
|
||||
* a web service you want to secure
|
||||
### Requirements
|
||||
|
||||
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
|
||||
- PHP 8.4+
|
||||
- Composer
|
||||
- Xdebug (for coverage reports)
|
||||
|
||||
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.
|
||||
## License
|
||||
|
||||
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.
|
||||
MIT — see `license.txt`.
|
||||
|
||||
I say login, but it's really just a TOTP code (6-digit code which changes every 30 second). But once they enter the right code,they'll get their cookie and be shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
|
||||
## Project Status
|
||||
|
||||
First time you spin up the docker container it will generate a TOTP secret (which you'll load into your authenticator app); or generate you own.
|
||||
Running in production since June 2024, protecting multiple self-hosted
|
||||
services. The core authentication gate is complete and battle-tested.
|
||||
|
||||
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
|
||||
|
||||
## Backup Codes
|
||||
|
||||
It is possible to generate single-use backup codes via a console command within the docker container.
|
||||
|
||||
```shell
|
||||
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
|
||||
```
|
||||
|
||||
### History
|
||||
#### v0.7.0 (May 29th, 2026)
|
||||
Added ability to generate single-use backup codes.
|
||||
Removed static password and lookup token, as they were security risks.
|
||||
Updated to PHP 8.5, updated dependencies.
|
||||
|
||||
#### v0.6.0 (Feb 10th, 2026)
|
||||
Added optional (disabled by default) ability to lookup token by static password.
|
||||
|
||||
#### v0.5.0 (Jan 17th, 2026)
|
||||
Nonce related cleanup; added optional (disabled by default) ability to use a static password as a backup means of authentication.
|
||||
|
||||
#### v0.4.1 (Dec 26th, 2025)
|
||||
Fixed bug which can occur if you delete cache files.
|
||||
|
||||
#### v0.4.0 (Dec 26th, 2025)
|
||||
Massive rewrite to switch to using listeners instead of controller, header for login payload instead of get request, removed icon system, asset system, was able to remove all the domain processing, enhanced cookie security, and more.
|
||||
|
||||
#### v0.3.0 (Dec 15th, 2025)
|
||||
Includes significant breaking changes.
|
||||
Default port and transportation changed to http via port 80.
|
||||
Names of environment variables have changed.
|
||||
|
||||
#### v0.2.0 (Dec 3rd, 2025)
|
||||
Now with login rate limiting.
|
||||
New page for client error (too many requests).
|
||||
Made example docker compose.
|
||||
|
||||
#### v0.1.0 (Nov 14th, 2025)
|
||||
Now an actual project, docker image pushed to docker hub, which uses php-fpm, code into a src folder, templates into separate files.
|
||||
|
||||
#### v0.0.1 (June 26th, 2024)
|
||||
Started off as a single file script which was part of my caddy config. Hardcoded TOTP secret, zero flexibility, but functional. Would stay like that, quietly working in production for about a full year before any real change.
|
||||
See `ROADMAP.md` for planned features and `CHANGELOG.md` for version history.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* Shared application constants.
|
||||
*/
|
||||
final class AppConstants
|
||||
{
|
||||
/**
|
||||
* Far-future expiration date used for persistent cache items
|
||||
* (TOTP secrets, backup codes) that should effectively never expire.
|
||||
* Per PSR-6, if no expiration is set, the implementation may set a
|
||||
* default — we use this to be explicit.
|
||||
*/
|
||||
public const string FAR_FUTURE_DATE = '2999-12-31';
|
||||
|
||||
/**
|
||||
* Maximum length for user-supplied input fields (id, nonce, token).
|
||||
* Also used for cache key truncation.
|
||||
*/
|
||||
public const int MAX_INPUT_LENGTH = 128;
|
||||
}
|
||||
@@ -7,13 +7,16 @@ namespace App\Command;
|
||||
use App\PersistCache;
|
||||
use App\Service\BackupCodeInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
|
||||
|
||||
/** simple console command to generate backup codes
|
||||
* usage: php bin/console app:generate-backup-codes [count] */
|
||||
#[AsCommand(name: 'app:generate-backup-codes')]
|
||||
final class GenerateBackupCodesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
@@ -25,8 +28,7 @@ final class GenerateBackupCodesCommand extends Command
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('app:generate-backup-codes');
|
||||
$this->setDescription('Generate single‑use backup codes')
|
||||
$this->setDescription('Generate single-use backup codes')
|
||||
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
|
||||
}
|
||||
|
||||
@@ -36,6 +38,9 @@ final class GenerateBackupCodesCommand extends Command
|
||||
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
|
||||
$this->persistCache->boot();
|
||||
$count = (int) $input->getArgument('count');
|
||||
if ($count < 1) {
|
||||
throw new ConsoleInvalidArgumentException('Count must be a positive integer.');
|
||||
}
|
||||
$codes = $this->manager->generate($count);
|
||||
foreach ($codes as $code) {
|
||||
$output->writeln($code);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Enum\RemoteUserMode;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -18,6 +19,10 @@ final readonly class ConfigBag
|
||||
private string $errorMessage;
|
||||
private string $teapotTitle;
|
||||
private string $tooManyTitle;
|
||||
private RemoteUserMode $remoteUserMode;
|
||||
private string $remoteUserStatic;
|
||||
/** @var array<string,string> */
|
||||
private array $remoteUserMap;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
@@ -30,6 +35,9 @@ final readonly class ConfigBag
|
||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
||||
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
||||
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
|
||||
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
|
||||
) {
|
||||
$this->clock = $clock;
|
||||
$this->cookieTtl = $cookieTtl;
|
||||
@@ -39,6 +47,31 @@ final readonly class ConfigBag
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->teapotTitle = $teapotTitle;
|
||||
$this->tooManyTitle = $tooManyTitle;
|
||||
|
||||
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
||||
$this->remoteUserStatic = $remoteUserStatic;
|
||||
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated map string ("id1:user1,id2:user2") into an array.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function parseUserMap(string $map): array
|
||||
{
|
||||
if ($map === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (explode(',', $map) as $pair) {
|
||||
$parts = explode(':', trim($pair), 2);
|
||||
if (count($parts) === 2) {
|
||||
$result[trim($parts[0])] = trim($parts[1]);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function clock(): ClockInterface
|
||||
@@ -80,4 +113,22 @@ final readonly class ConfigBag
|
||||
{
|
||||
return $this->tooManyTitle;
|
||||
}
|
||||
|
||||
public function remoteUserMode(): RemoteUserMode
|
||||
{
|
||||
return $this->remoteUserMode;
|
||||
}
|
||||
|
||||
public function remoteUserStatic(): string
|
||||
{
|
||||
return $this->remoteUserStatic;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function remoteUserMap(): array
|
||||
{
|
||||
return $this->remoteUserMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\Enum\Scope;
|
||||
use Symfony\Component\HttpFoundation\InputBag;
|
||||
|
||||
@@ -59,11 +60,11 @@ final class Payload
|
||||
|
||||
/* all input is limited */
|
||||
$payload = new Payload();
|
||||
$payload->id = mb_substr(trim($data->id), 0, 128);
|
||||
$payload->nonce = mb_substr(trim($data->nonce), 0, 128);
|
||||
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
$payload->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
$payload->json = ($data->json ?? true);
|
||||
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
||||
$payload->token = mb_substr(trim($data->token), 0, 128);
|
||||
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
|
||||
return Payload::constrict($payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Controls what value is sent in the Remote-User header on auth success.
|
||||
*/
|
||||
enum RemoteUserMode: string
|
||||
{
|
||||
/** Send the session id (current/default behaviour). */
|
||||
case Session = 'session';
|
||||
|
||||
/** Send a fixed static string for all authenticated requests. */
|
||||
case Static = 'static';
|
||||
|
||||
/** Look up the session id in a configured map and send the mapped value. */
|
||||
case Mapped = 'mapped';
|
||||
|
||||
/** Do not send the Remote-User header at all. */
|
||||
case None = 'none';
|
||||
}
|
||||
+5
-3
@@ -28,8 +28,10 @@ class Kernel extends BaseKernel
|
||||
/** @throws InvalidArgumentException */
|
||||
public function terminate(Request $request, Response $response): void
|
||||
{
|
||||
$this->persistCache->persist();
|
||||
|
||||
parent::terminate($request, $response);
|
||||
try {
|
||||
$this->persistCache->persist();
|
||||
} finally {
|
||||
parent::terminate($request, $response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
@@ -11,7 +12,6 @@ use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AcceptListener
|
||||
@@ -23,27 +23,40 @@ final readonly class AcceptListener
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
#[AsEventListener(priority: 99)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
/* check if they sent the correct preauth cookie */
|
||||
$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");
|
||||
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
|
||||
/* cookie sent corresponds to valid existing session */
|
||||
$id = $this->sessionCache->getItem($cookieKey)->get();
|
||||
$this->logger->debug("has valid cookie-session: $id");
|
||||
$event->setResponse(new Response("hi $id", headers: [
|
||||
'Content-Type' => 'text/plain',
|
||||
'Remote-User' => $id,
|
||||
]));
|
||||
$cookieName = $this->sessionCookieName($this->domainManager);
|
||||
if (! $event->getRequest()->cookies->has($cookieName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cookie = $event->getRequest()->cookies->get($cookieName);
|
||||
$cookieKey = $this->makeCacheKey("cookie_$cookie");
|
||||
|
||||
try {
|
||||
if (! $cookie || ! $this->sessionCache->hasItem($cookieKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* cookie sent corresponds to valid existing session */
|
||||
$item = $this->sessionCache->getItem($cookieKey);
|
||||
if (! $item->isHit()) {
|
||||
/* race condition: item was removed between hasItem and getItem */
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $item->get();
|
||||
$this->logger->debug("has valid cookie-session: $id");
|
||||
$event->setResponse($this->authSuccessResponse($id, $this->config));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
/* cache failure — fail closed (don't authenticate) */
|
||||
$this->logger->error("cache error in AcceptListener: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final readonly class AllowListener
|
||||
@@ -24,21 +23,33 @@ final readonly class AllowListener
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
#[AsEventListener(priority: 88)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if ($this->config->ipTtl() > 0) {
|
||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||
if ($this->sessionCache->hasItem($ipKey)) {
|
||||
/* ip address corresponds to valid existing session */
|
||||
$id = $this->sessionCache->getItem($ipKey)->get();
|
||||
$this->logger->debug("has valid ip-session: $id");
|
||||
$event->setResponse(new Response("hi $id", headers: [
|
||||
'Content-Type' => 'text/plain',
|
||||
'Remote-User' => $id,
|
||||
]));
|
||||
if ($this->config->ipTtl() <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
|
||||
|
||||
try {
|
||||
if (! $this->sessionCache->hasItem($ipKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* ip address corresponds to valid existing session */
|
||||
$item = $this->sessionCache->getItem($ipKey);
|
||||
if (! $item->isHit()) {
|
||||
/* race condition: item was removed between hasItem and getItem */
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $item->get();
|
||||
$this->logger->debug("has valid ip-session: $id");
|
||||
$event->setResponse($this->authSuccessResponse($id, $this->config));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
/* cache failure — fail closed (don't authenticate) */
|
||||
$this->logger->error("cache error in AllowListener: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ final readonly class InterceptListener
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
||||
]);
|
||||
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
||||
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
|
||||
$this->sessionCookieName($this->domainManager)
|
||||
);
|
||||
$event->setResponse($this->pruneInvalidCookie(new Response(
|
||||
$content,
|
||||
@@ -69,12 +69,10 @@ final readonly class InterceptListener
|
||||
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response
|
||||
{
|
||||
if ($hasCookie) {
|
||||
/* input here must match LoginListener::setCookie() */
|
||||
$response->headers->clearCookie(
|
||||
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
|
||||
$this->sessionCookieName($this->domainManager),
|
||||
'/',
|
||||
/* if using central auth, only set the domain if the host matches */
|
||||
$this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
|
||||
$this->sessionCookieDomain($this->domainManager, $host),
|
||||
true,
|
||||
true,
|
||||
Cookie::SAMESITE_STRICT
|
||||
|
||||
@@ -24,6 +24,15 @@ use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
/**
|
||||
* Handles login attempts via X-Preauth header (AJAX) or POST form submission.
|
||||
*
|
||||
* CSRF Protection: The nonce field serves as CSRF protection for the POST form
|
||||
* path. Nonces are server-generated, single-use, and have a 120-second TTL.
|
||||
* An attacker cannot forge a POST request without first loading the login page
|
||||
* to obtain a valid nonce, which requires being on the auth subdomain.
|
||||
* For the AJAX (header) path, the nonce is embedded in the base64url payload.
|
||||
*/
|
||||
final readonly class LoginListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
@@ -81,9 +90,9 @@ final readonly class LoginListener
|
||||
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
|
||||
$event->setResponse($this->makeFailedResponse(
|
||||
$limitReached,
|
||||
$payload->json ?? true,
|
||||
$payload?->json ?? true,
|
||||
$event->getRequest()->getHost(),
|
||||
$this->makeCacheKey($payload ? $payload->id : '')
|
||||
$this->makeCacheKey($payload?->id ?? '')
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PublicPathMatcherInterface;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
/**
|
||||
* Allows rate-limited unauthenticated access to configured public paths.
|
||||
*
|
||||
* Runs at priority 84 — after AcceptListener (99) and AllowListener (88)
|
||||
* so authenticated users bypass this listener entirely, but before
|
||||
* RejectListener (77) and LoginListener (66) so public traffic is not
|
||||
* subject to the login rate limiter.
|
||||
*
|
||||
* When the request path matches a configured public path pattern:
|
||||
* - If within rate limit → 200 OK (no Remote-User header)
|
||||
* - If over rate limit → 429 Too Many Requests with Retry-After header
|
||||
*
|
||||
* Non-matching paths fall through to the normal auth flow.
|
||||
*/
|
||||
final readonly class PublicAccessListener
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
public function __construct(
|
||||
private PublicPathMatcherInterface $pathMatcher,
|
||||
private DomainInterface $domainManager,
|
||||
private Environment $twig,
|
||||
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
}
|
||||
|
||||
/** @throws SyntaxError|RuntimeError|LoaderError */
|
||||
#[AsEventListener(priority: 84)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if ($this->pathMatcher->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
$host = $request->getHost();
|
||||
$path = $request->getPathInfo();
|
||||
|
||||
// Never treat the auth subdomain itself as public
|
||||
if ($this->domainManager->getAuthSubdomain() === $host) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->pathMatcher->matches($host, $path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Path is public — apply rate limiting
|
||||
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||
$limit = $limiter->consume(1);
|
||||
|
||||
if ($limit->isAccepted()) {
|
||||
$this->logger->debug("public access granted: {$request->getClientIp()} -> $path");
|
||||
$event->setResponse(new Response(
|
||||
'',
|
||||
Response::HTTP_OK,
|
||||
[
|
||||
'Content-Type' => 'text/plain',
|
||||
'Retry-After' => (string) $limit->getRemainingTokens(),
|
||||
],
|
||||
));
|
||||
} else {
|
||||
$retryAfter = $limit->getRetryAfter()?->getTimestamp() - time();
|
||||
$retryAfter = max(1, $retryAfter);
|
||||
|
||||
$this->logger->debug("public access rate-limited: {$request->getClientIp()} -> $path");
|
||||
$html = $this->twig->render('error.html.twig');
|
||||
$event->setResponse(new Response(
|
||||
$html,
|
||||
Response::HTTP_TOO_MANY_REQUESTS,
|
||||
[
|
||||
'Content-Type' => 'text/html',
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
|
||||
/**
|
||||
* Adds security-related HTTP response headers to all responses.
|
||||
* These headers help protect against XSS, clickjacking, MIME-type
|
||||
* sniffing, and referrer leakage.
|
||||
*/
|
||||
final readonly class SecurityHeadersListener
|
||||
{
|
||||
#[AsEventListener(priority: 0)]
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (! $event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $event->getResponse();
|
||||
$headers = $response->headers;
|
||||
|
||||
/* prevent MIME-type sniffing */
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
/* prevent clickjacking — this app is never framed */
|
||||
$headers->set('X-Frame-Options', 'DENY');
|
||||
|
||||
/* control referrer information sent to other sites */
|
||||
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
/* Content-Security-Policy — the login page uses inline styles
|
||||
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
||||
* for those. No external resources are loaded. */
|
||||
$headers->set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
|
||||
);
|
||||
|
||||
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
||||
$headers->set('Strict-Transport-Security', 'max-age=31536000');
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
$this->cache->save($changeList);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function getItem(string $key): CacheItemInterface
|
||||
{
|
||||
return $this->cache->getItem($key);
|
||||
@@ -79,6 +80,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
return $this->cache->getItems($keys);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function hasItem(string $key): bool
|
||||
{
|
||||
return $this->cache->hasItem($key);
|
||||
@@ -97,6 +99,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function deleteItem(string $key): bool
|
||||
{
|
||||
$this->isValid($key);
|
||||
@@ -113,6 +116,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
return $this->cache->deleteItem($key);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function deleteItems(array $keys): bool
|
||||
{
|
||||
$this->allValid($keys);
|
||||
@@ -145,6 +149,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
return $this->cache->saveDeferred($item);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function commit(): bool
|
||||
{
|
||||
return $this->cache->commit();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Exception;
|
||||
@@ -13,7 +15,7 @@ interface BackupCodeInterface
|
||||
* @param int $count Number of codes to generate
|
||||
* @return string[] Generated backup codes
|
||||
* @throws InvalidArgumentException|Exception */
|
||||
public function generate(int $count = 0): array;
|
||||
public function generate(int $count = 10): array;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function expire(): void;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
@@ -75,7 +76,7 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
/* 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));
|
||||
$this->logger->debug("checking backup code '{$backupKey}': " . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
|
||||
$this->logger->debug('checking backup code: ' . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
|
||||
if ($backupItem->isHit() && $backupItem->get()) {
|
||||
$this->logger->debug("valid backup code");
|
||||
/* mark backup code as spent */
|
||||
@@ -84,7 +85,7 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
AppConstants::FAR_FUTURE_DATE
|
||||
));
|
||||
$this->sessionCache->save($backupItem);
|
||||
|
||||
@@ -104,7 +105,7 @@ final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
AppConstants::FAR_FUTURE_DATE
|
||||
));
|
||||
$this->sessionCache->saveDeferred($backupItem);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
interface DomainInterface
|
||||
|
||||
@@ -12,19 +12,98 @@ final readonly class DomainManager implements DomainInterface
|
||||
private const array TLD = [
|
||||
'ai' => ['com','net','off','org'],
|
||||
'am' => ['radio'],
|
||||
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za'],
|
||||
'at' => ['ac','co','gv','or'],
|
||||
'au' => ['com','net','org','edu','gov','asn','id'],
|
||||
'az' => ['com','net','org'],
|
||||
'bd' => ['com','net','org','gov','mil','ac'],
|
||||
'br' => ['com','net','org','gov','mil','eco','emp','g12','ind','inf','rec','tur','tv','edu','far','gov','gru','jor','leg','lec','med','nom','not','ppg','pro','psi','pub','slg','srv','tec','tmp','vip','vlog','wiki','zlg'],
|
||||
'by' => ['com','net','org','gov','mil','of'],
|
||||
'ca' => ['ab','bc','mb','nb','nf','nl','ns','nt','nu','on','pe','qc','sk','yk'],
|
||||
'cc' => [],
|
||||
'cn' => ['com','net','org','gov','edu','ac','bj','sh','tj','cq','he','sx','nm','ln','jl','hl','js','zj','ah','fj','jx','sd','ha','hb','hn','gd','gx','hi','sc','gz','yn','sn','gs','qh','nx','xj','tw','hk','mo'],
|
||||
'co' => ['com','net','org','gov','mil','edu','arts','firm','info','int','nom','rec','web'],
|
||||
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za','au','bh','bo','cn','ec','eg','gt','hk','hn','il','in','jp','kr','kw','lb','lv','my','mx','ng','ni','np','pe','pf','pg','ph','pk','pl','pr','py','sa','sg','sv','tr','tw','ua','uy','ve','vn','ye'],
|
||||
'de' => ['com'],
|
||||
'dk' => ['co'],
|
||||
'ec' => ['com','net','org','gov','mil','edu','fin','med','pro'],
|
||||
'ee' => ['com','org','pri'],
|
||||
'eg' => ['com','net','org','gov','edu','mil'],
|
||||
'es' => ['com','nom','org','edu','gob'],
|
||||
'eu' => [],
|
||||
'fi' => ['aland'],
|
||||
'fm' => ['radio'],
|
||||
'fr' => ['com','nom','tm','asso','gouv','pol'],
|
||||
'ge' => ['com','net','org','edu','gov','mil'],
|
||||
'gg' => ['co','net','org'],
|
||||
'in' => ['co','firm','gen','ind','net','org'],
|
||||
'gr' => ['com','net','org','gov','edu','mil'],
|
||||
'hk' => ['com','net','org','gov','edu','idv'],
|
||||
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'],
|
||||
'id' => ['ac','biz','co','desa','go','mil','my','net','or','sch','web'],
|
||||
'ie' => ['gov'],
|
||||
'il' => ['ac','co','gov','idf','k12','muni','net','org'],
|
||||
'in' => ['co','firm','gen','ind','net','org','ac','edu','res','gov','mil'],
|
||||
'iq' => ['com','net','org','gov','edu','mil'],
|
||||
'ir' => ['ac','co','gov','id','net','org','sch'],
|
||||
'is' => ['net','com','org','edu','gov','int'],
|
||||
'it' => ['ab','ag','al','an','ao','ap','aq','ar','at','av','ba','bg','bi','bl','bn','bo','br','bs','bt','bz','ca','cb','ce','ch','cl','cn','co','cr','cs','ct','cz','en','fc','fe','fg','fi','fm','fr','ge','go','gr','im','is','kr','lc','le','li','lo','lt','lu','mb','mc','me','mi','mn','mo','ms','mt','na','no','nu','or','pa','pc','pd','pe','pg','pi','pn','po','pr','pt','pu','pv','pz','re','rg','ri','rm','rn','ro','sa','si','so','sp','sr','ss','su','sv','ta','te','tn','to','tp','tr','ts','tv','ud','va','vb','vc','ve','vi','vr','vt','vv','edu','gov','abruzzo','basilicata','calabria','campania','emilia-romagna','friuli-ve-giulia','lazio','liguria','lombardia','marche','molise','piemonte','puglia','sardegna','sicilia','toscana','trentino-a-adige','umbria','valle-aosta','veneto'],
|
||||
'je' => ['co','net','org'],
|
||||
'mx' => ['com','net','org'],
|
||||
'net' => ['gb','hu','in','jp','se','uk'],
|
||||
'nz' => ['co','net','org'],
|
||||
'org' => ['ae','us'],
|
||||
'ph' => ['com','net','org'],
|
||||
'se' => ['com'],
|
||||
'uk' => ['co','me','org'],
|
||||
'jo' => ['com','net','org','gov','edu','mil','sch'],
|
||||
'jp' => ['ac','ad','co','ed','go','gr','lg','ne','or'],
|
||||
'ke' => ['co','ne','or','ac','go','me','mobi','info','sc','pro'],
|
||||
'kg' => ['com','net','org','gov','mil','edu'],
|
||||
'kr' => ['ac','co','go','hs','kg','mil','ms','ne','or','pe','re','seoul','busan','daegu','incheon','gwangju','daejeon','ulsan','gyeonggi','gangwon','chungbuk','chungnam','jeonbuk','jeonnam','gyeongbuk','gyeongnam','jeju','sejong'],
|
||||
'kz' => ['com','net','org','edu','gov','mil'],
|
||||
'li' => [],
|
||||
'lt' => ['gov'],
|
||||
'lv' => ['com','net','org','edu','gov','mil','id','asn','conf'],
|
||||
'ly' => ['com','net','org','gov','edu','sch','med','id'],
|
||||
'ma' => ['co','net','org','gov','press','ac'],
|
||||
'mk' => ['com','net','org','edu','gov','inf','name','pro'],
|
||||
'mx' => ['com','net','org','gov','edu','mil'],
|
||||
'my' => ['com','net','org','gov','edu','mil','name'],
|
||||
'na' => ['com','net','org','alt','edu','gov','mil','pro'],
|
||||
'net' => ['gb','hu','in','jp','se','uk','cn','nz'],
|
||||
'ng' => ['com','net','org','gov','edu','mil','sch','name','gov'],
|
||||
'ni' => ['ac','co','com','edu','gob','mil','net','nom','org'],
|
||||
'nl' => ['bv','co'],
|
||||
'no' => ['fhs','folkebibl','kommune','mil','stat','priv','vgs','dep','kommune'],
|
||||
'nz' => ['co','net','org','ac','geek','gen','maori','school','parliament','govt','health','mil','crii','archie','geek','govt','health','maori','school'],
|
||||
'om' => ['com','net','org','gov','edu','med','mil','sch'],
|
||||
'org' => ['ae','us','lu'],
|
||||
'pe' => ['com','net','org','gob','edu','mil','nom'],
|
||||
'ph' => ['com','net','org','gov','edu','mil'],
|
||||
'pk' => ['com','net','org','fam','biz','edu','gov','web'],
|
||||
'pl' => ['com','net','org','aid','agro','atm','auto','biz','edu','gmina','gsm','info','mail','miasta','media','mil','ngo','nom','pc','powiat','priv','realestate','rel','sex','shop','sklep','sos','szkola','targi','tm','tourism','travel','turystyka','gov','ap','augov','bedzin','bialystok','bielawa','bierun','boleslawiec','bydgoszcz','bytom','cieszyn','czeladz','czest','dlugoleka','elblag','elk','glogow','gniezno','gorlice','gorzow','grodzisk','grudziadz','ilk','jaworzno','jelenia-gora','jgora','kalisz','kazimierz-dolny','karpacz','kartuzy','kaszuby','katowice','kepno','ketrzyn','klodzko','kobierzyce','kolobrzeg','konin','konskowola','krapkowice','krakow','krasnik','krasno','krosniewice','kutno','lapy','lebork','legnica','lezajsk','limanowa','lomza','lowicz','lubin','lukow','malbork','malopolska','mazowsze','mazury','mielec','milicz','mielno','mragowo','naklo','nowaruda','nysa','olawa','olecko','olkusz','olsztyn','opoczno','opole','ostrowiec','ostroleka','ostrowwlkp','pila','pisz','podhale','podlasie','polkowice','pomorze','pomorse','prochowice','pruszkow','przeworsk','pulawy','rabka','rawa-maz','rybnik','rzeszow','sanok','sejny','siedlce','slask','slupsk','sosnowiec','stalowa-wola','skoczow','starachowice','stargard','suwalki','swidnica','swiebodzin','swinoujscie','szczecin','szczytno','tarnobrzeg','tgory','turek','tychy','ustka','walbrzych','warmia','warszawa','waw','wegrow','wielun','wlocl','wloclawek','wodzislaw','wolomin','wroclaw','zachpomor','zagan','zarow','zgora','zgorzelec','plug'],
|
||||
'pr' => ['ac','co','edu','gov','info','island','pro','net','org'],
|
||||
'pt' => ['com','net','org','gov','edu','int','publ'],
|
||||
'py' => ['com','net','org','gov','edu','mil','co'],
|
||||
'qa' => ['com','net','org','gov','edu','mil','sch','name'],
|
||||
'ro' => ['com','net','org','nom','rec','info','arts','com','firm','tm','www','store','nt','ngo','pro','tm','com','arts','rec','store','info','nom','nt','org','shop','firm','www','rest','travel','transport','tourism','press','media','medical','med','law','jobs','inst','individual','insinfo','guru','fit','engineering','expert','energy','economy','dot','dog','dev','design','dem','dental','craft','corp','consulting','construction','company','com','club','cloud','coach','city','cinema','church','chat','casino','cars','care','cards','broke','blog','bio','bid','band','auto','audio','attorney','apartments','app','art','archi','architects','arena','architects','associates','attorney','auction','auto','baby','band','bank','bar','bargains','beer','berlin','best','bet','bid','bike','bingo','bio','black','blog','blue','boats','bond','boo','book','boutique','build','builders','business','buzz','cab','cafe','call','cam','camp','capital','care','careers','cars','cash','casino','catering','center','ceo','ceramics','cfd','ch','chat','church','city','claims','cleaning','click','clinic','clothing','cloud','club','coach','codes','coffee','college','community','company','computer','condos','construction','consulting','contact','cooking','cool','country','courses','cpa','craft','credit','creditcard','cricket','cruise','cuisinella','cymru','dabur','dance','date','dating','deals','degree','delivery','democrat','dental','design','dev','diamonds','diet','digital','direct','directory','discount','dog','domains','doos','download','ec','edu','education','energy','engineering','enterprises','equipment','estate','events','exchange','expert','exposed','express','fail','faith','family','fan','farm','fashion','film','finance','financial','fish','fit','fitness','flights','florist','flowers','football','forex','forsale','foundation','fun','fund','furniture','futbol','fyi','gal','gallery','game','garden','gift','gifts','gives','glass','global','gold','golf','graphics','gratis','green','gripe','group','guru','health','healthcare','help','helsinki','here','hiphop','hiv','holdings','holiday','homes','horse','host','hosting','house','how','immo','immobilien','in','industries','info','ink','institute','insure','international','investments','irish','jewelry','kaufen','kids','kim','kitchen','kiwi','kred','land','law','lawyer','legal','lgbt','lifestyle','lighting','limited','limo','link','live','loan','loans','lol','london','love','ltd','ltda','luxury','maison','management','market','marketing','markets','media','memorial','men','menu','miami','mobi','moda','moe','mom','money','monster','mortgage','movie','nagoya','name','navy','net','network','news','ngo','ninja','nyc','observer','okinawa','one','ong','onl','online','ooo','org','organic','osaka','paris','partners','parts','party','photo','photography','photos','pics','pictures','pink','pizza','place','plumbing','plus','poker','porn','press','pro','productions','properties','property','pub','qpon','realtor','realty','recipes','red','rehab','reise','reisen','rent','rentals','repair','report','rest','restaurant','review','reviews','rich','rip','rocks','rodeo','run','saarland','sale','salon','sarl','save','saxo','school','schule','science','services','sex','sexy','sg','shop','shopping','show','singles','site','ski','soccer','social','software','solar','solutions','space','store','stream','studio','study','style','supplies','supply','support','surgery','systems','tax','taxi','team','tech','technology','tennis','thai','tips','tires','tirol','today','tokyo','tools','top','tour','tours','town','toys','trade','trading','training','travel','tube','university','uno','vacations','vegas','ventures','vet','viajes','video','villas','vin','vision','vlaanderen','vodka','vote','voting','voto','voyage','wales','watch','webcam','website','wedding','wien','wiki','win','wine','work','works','world','wtf','xxx','xyz','yoga','yokohama','zone'],
|
||||
'ru' => ['ac','com','edu','int','net','org','pp','adygeya','altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey'],
|
||||
'sa' => ['com','net','org','gov','med','pub','edu','sch'],
|
||||
'sb' => ['com','net','org','edu','gov'],
|
||||
'sc' => ['com','net','org','gov','edu'],
|
||||
'se' => ['a','ac','b','bd','brand','c','d','e','f','fh','fhsk','fhv','g','h','i','k','komforb','kommunal','komvux','kunskapsforb','l','lanbib','m','n','naturbruksgymn','o','org','p','parti','pp','press','r','s','t','tm','u','v','w','x','y','z'],
|
||||
'sg' => ['com','net','org','gov','edu','per'],
|
||||
'sh' => ['com','net','org','gov','mil','edu'],
|
||||
'sk' => ['co','com','edu','gov','mil','net','org','nfo'],
|
||||
'st' => ['co','com','consulado','edu','embaixada','gov','mil','net','org','principe','saotome','store'],
|
||||
'su' => ['abkhazia','adygeya','ak', 'altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey','com','net','org','gov','pp','edu'],
|
||||
'sv' => ['com','edu','gob','org','red'],
|
||||
'sy' => ['com','net','org','gov','edu','mil','name'],
|
||||
'th' => ['ac','co','go','in','mi','net','or'],
|
||||
'tj' => ['ac','biz','co','com','edu','gov','go','info','int','mil','name','net','nic','nom','org','pro','test','web'],
|
||||
'tn' => ['agrinet','com','defense','edunet','ens','fin','gov','ind','info','intl','min','nat','net','org','perso','rnrt','rns','rnu','tourism','turen'],
|
||||
'tr' => ['com','net','org','gov','biz','info','mil','edu','tv','bbs','k12','pol','bel','dr','gen','av','bbs','k12','name','tel','nc','web','tsk','bel','pol','edu'],
|
||||
'tw' => ['com','net','org','edu','gov','mil','idv','game','ebiz','club','gnu'],
|
||||
'ua' => ['com','net','org','edu','gov','in','at','cn','crimea','dn','dnepropetrovsk','donetsk','dp','if','ivano-frankivsk','kh','kharkov','kherson','khmelnitskiy','kiev','kirovograd','km','kr','ks','kv','lg','lt','lugansk','lutsk','lv','lviv','mk','mk.ua','mykolaiv','net','nikolaev','od','odessa','pl','poltava','rovno','rv','sebastopol','sm','sumy','te','ternopil','uz','uzhgorod','vinnica','vn','volyn','yalta','zaporizhzhe','zhitomir','zp','zt'],
|
||||
'uk' => ['co','me','org','ltd','plc','net','sch','ac','gov','nhs','police','mod','nhs','parliament'],
|
||||
'us' => ['ak','al','ar','as','az','ca','co','ct','dc','de','fl','ga','gu','hi','ia','id','il','in','ks','ky','la','ma','md','me','mi','mn','mo','ms','mt','nc','nd','ne','nh','nj','nm','nv','ny','oh','ok','or','pa','pr','ri','sc','sd','tn','tx','ut','vi','vt','va','wa','wi','wv','wy','dni','fed','isa','kids','nsn'],
|
||||
'uy' => ['com','net','org','gub','mil','edu'],
|
||||
've' => ['co','com','edu','gob','info','net','org','web'],
|
||||
'vn' => ['com','net','org','edu','gov','int','ac','biz','info','name','pro','health'],
|
||||
'yu' => ['ac','co','edu','gov','org'],
|
||||
'za' => ['ac','alt','co','edu','gov','law','mil','net','ngo','nom','org','school','tm','web'],
|
||||
];
|
||||
|
||||
private bool $subdomainRedirect;
|
||||
@@ -61,7 +140,7 @@ final readonly class DomainManager implements DomainInterface
|
||||
|
||||
if ($this->authBase()) {
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === null) {
|
||||
if ($host === null || $host === false || $host === '') {
|
||||
return false;
|
||||
}
|
||||
/* do not send the user to another domain */
|
||||
@@ -104,7 +183,7 @@ final readonly class DomainManager implements DomainInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = explode('.', $host);
|
||||
$parts = explode('.', strtolower($host));
|
||||
$keep = $this->baseLength($parts);
|
||||
$parts = array_slice($parts, -$keep);
|
||||
return implode('.', $parts);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
|
||||
@@ -46,7 +46,7 @@ final readonly class LoginManager implements LoginInterface
|
||||
$payload->scope = Scope::Cookie;
|
||||
}
|
||||
|
||||
if ($this->getTotp()->verify($payload->token, null, 10) ||
|
||||
if ($this->getTotp()->verify($payload->token, null, 1) ||
|
||||
$this->backupCodeManager->verifyAndConsume($payload->token)
|
||||
) {
|
||||
/* token is correct (TOTP or Backup) */
|
||||
@@ -63,10 +63,7 @@ final readonly class LoginManager implements LoginInterface
|
||||
$cleanId = $this->makeCacheKey($payload->id);
|
||||
|
||||
/* if they just want this one page, return ok, to grant them access */
|
||||
$response = new Response("hi $cleanId", headers: [
|
||||
'Content-Type' => 'text/plain',
|
||||
'Remote-User' => $cleanId,
|
||||
]);
|
||||
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||
|
||||
if ($payload->scope !== Scope::None) {
|
||||
/* grant access based on the requested scope */
|
||||
@@ -123,16 +120,12 @@ final readonly class LoginManager implements LoginInterface
|
||||
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
||||
$this->sessionCache->save($sessionCookie);
|
||||
|
||||
/* when using subdomain-auth we have to use a different cookie name, as the
|
||||
* "__Host-Http-" prefix we normally use does not allow domain to be set */
|
||||
/* changes here must be reflected in InterceptListener::pruneInvalidCookie() */
|
||||
return Cookie::create(
|
||||
name: $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(),
|
||||
name: $this->sessionCookieName($this->domainManager),
|
||||
value: $ulid->toString(),
|
||||
expire: time() + $this->config->cookieTtl(),
|
||||
path: '/',
|
||||
/* if using central auth, only set the domain if the host matches */
|
||||
domain: $this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
|
||||
domain: $this->sessionCookieDomain($this->domainManager, $host),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: Cookie::SAMESITE_STRICT,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Matches request paths against configured public path patterns.
|
||||
*
|
||||
* Patterns are provided as a comma-separated string in the format:
|
||||
* /path/pattern, host.example.com/path/pattern, or a mix.
|
||||
*
|
||||
* Wildcards:
|
||||
* - * matches any characters within a single path segment (not crossing /)
|
||||
* - ** matches any characters including / (crosses path segments)
|
||||
*
|
||||
* Query strings are not part of the pattern — matching is against the
|
||||
* path only.
|
||||
*/
|
||||
final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
||||
{
|
||||
/** @var list<array{host: ?string, regex: string}> */
|
||||
private array $patterns;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%app.public_paths%')] string $publicPaths,
|
||||
) {
|
||||
$this->patterns = $this->parse($publicPaths);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->patterns === [];
|
||||
}
|
||||
|
||||
public function matches(string $host, string $path): bool
|
||||
{
|
||||
if ($this->patterns === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = strtolower($host);
|
||||
|
||||
foreach ($this->patterns as $entry) {
|
||||
if ($entry['host'] !== null && $entry['host'] !== $host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($entry['regex'], $path) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the comma-separated PUBLIC_PATHS string into pattern entries.
|
||||
*
|
||||
* @return list<array{host: ?string, regex: string}>
|
||||
*/
|
||||
private function parse(string $publicPaths): array
|
||||
{
|
||||
if (trim($publicPaths) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$patterns = [];
|
||||
|
||||
foreach (explode(',', $publicPaths) as $raw) {
|
||||
$entry = trim($raw);
|
||||
if ($entry === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for a host prefix (anything before the first /)
|
||||
$host = null;
|
||||
$path = $entry;
|
||||
|
||||
if (preg_match('/^([a-z0-9.-]+)(\/.+)$/i', $entry, $m)) {
|
||||
$host = strtolower($m[1]);
|
||||
$path = $m[2];
|
||||
}
|
||||
|
||||
// Validate path starts with /
|
||||
if (!str_starts_with($path, '/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$patterns[] = [
|
||||
'host' => $host,
|
||||
'regex' => $this->compilePattern($path),
|
||||
];
|
||||
}
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a wildcard path pattern into a regex string.
|
||||
*
|
||||
* Star becomes a character class matching one or more non-slash chars.
|
||||
* Double-star at end of pattern matches zero or more of any char.
|
||||
* Double-star followed by slash matches zero or more path segments.
|
||||
* Other characters are escaped as literal regex.
|
||||
*/
|
||||
private function compilePattern(string $pattern): string
|
||||
{
|
||||
$regex = '';
|
||||
$length = strlen($pattern);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $length) {
|
||||
// Check for ** (must be at current position)
|
||||
if ($i + 1 < $length && $pattern[$i] === '*' && $pattern[$i + 1] === '*') {
|
||||
$i += 2;
|
||||
if ($i >= $length) {
|
||||
// ** at end of pattern: zero or more chars including /
|
||||
$regex .= '.*';
|
||||
} elseif ($pattern[$i] === '/') {
|
||||
// /**/ in middle: zero or more intermediate segments
|
||||
$regex .= '(?:.*/)?';
|
||||
$i += 1; // skip the / after **
|
||||
} else {
|
||||
// ** not followed by / or end, treat as .*
|
||||
$regex .= '.*';
|
||||
}
|
||||
} elseif ($pattern[$i] === '*') {
|
||||
$regex .= '[^/]+';
|
||||
$i += 1;
|
||||
} else {
|
||||
$regex .= preg_quote($pattern[$i], '#');
|
||||
$i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return '#^' . $regex . '$#';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Matches request paths against configured public path patterns.
|
||||
*
|
||||
* Patterns support simple wildcards:
|
||||
* - `*` matches any characters within a single path segment (not crossing `/`)
|
||||
* - `**` matches any characters including `/` (crosses path segments)
|
||||
*
|
||||
* Patterns may optionally include a host prefix (e.g. `example.com/public/**`).
|
||||
* When no host prefix is given, the pattern matches on any host.
|
||||
*/
|
||||
interface PublicPathMatcherInterface
|
||||
{
|
||||
/**
|
||||
* Returns true if the given host and path match any configured public pattern.
|
||||
*
|
||||
* @param string $host The request host (e.g. "code.example.com")
|
||||
* @param string $path The request path (e.g. "/public/repo/issues")
|
||||
*/
|
||||
public function matches(string $host, string $path): bool;
|
||||
|
||||
/**
|
||||
* Returns true if no public paths are configured (feature is disabled).
|
||||
*/
|
||||
public function isEmpty(): bool;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
use App\Service\DomainInterface;
|
||||
|
||||
trait CookieNameTrait
|
||||
{
|
||||
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||
@@ -24,4 +26,23 @@ trait CookieNameTrait
|
||||
{
|
||||
return static::HEADER_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate cookie name based on whether central auth is active.
|
||||
* Uses the __Host- prefix for single-domain mode (no Domain attribute),
|
||||
* and a non-prefixed name for central auth (Domain attribute required).
|
||||
*/
|
||||
final protected function sessionCookieName(DomainInterface $domainManager): string
|
||||
{
|
||||
return $domainManager->authBase() ? $this->authCookieName() : $this->cookieName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cookie domain for central auth mode, or null for single-domain.
|
||||
* The domain is only set when the host matches the auth base domain.
|
||||
*/
|
||||
final protected function sessionCookieDomain(DomainInterface $domainManager, string $host): ?string
|
||||
{
|
||||
return $domainManager->matchesAuth($host) ? $domainManager->authBase() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Trait;
|
||||
use App\ConfigBag;
|
||||
use OTPHP\Factory;
|
||||
use OTPHP\TOTPInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
@@ -29,6 +30,6 @@ trait GetTotpTrait
|
||||
if ($otp instanceof TOTPInterface) {
|
||||
return $otp;
|
||||
}
|
||||
throw new HttpException(500, 'Internal Server Exception');
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\ConfigBag;
|
||||
use App\Enum\RemoteUserMode;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
trait StringTrait
|
||||
{
|
||||
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
|
||||
@@ -11,6 +16,38 @@ trait StringTrait
|
||||
|
||||
public function makeCacheKey(string $name): string
|
||||
{
|
||||
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
|
||||
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plain-text success response body and headers for an
|
||||
* authenticated request. The body is a simple greeting that includes
|
||||
* the session id. The Remote-User header is set (or omitted) based on
|
||||
* the configured remote-user mode.
|
||||
*/
|
||||
public function authSuccessResponse(string $id, ConfigBag $config): Response
|
||||
{
|
||||
$headers = ['Content-Type' => 'text/plain'];
|
||||
|
||||
$headerValue = $this->resolveRemoteUser($id, $config);
|
||||
if ($headerValue !== null) {
|
||||
$headers['Remote-User'] = $headerValue;
|
||||
}
|
||||
|
||||
return new Response("hi $id", headers: $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Remote-User header value based on the configured mode.
|
||||
* Returns null when the header should not be sent.
|
||||
*/
|
||||
private function resolveRemoteUser(string $id, ConfigBag $config): ?string
|
||||
{
|
||||
return match ($config->remoteUserMode()) {
|
||||
RemoteUserMode::Session => $id,
|
||||
RemoteUserMode::Static => $config->remoteUserStatic(),
|
||||
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
|
||||
RemoteUserMode::None => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ final readonly class Utilities
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
AppConstants::FAR_FUTURE_DATE
|
||||
));
|
||||
$this->appPool->save($totpItem);
|
||||
return $totp;
|
||||
|
||||
@@ -53,9 +53,7 @@ form.addEventListener('submit', (event) => {
|
||||
console.log('got html response');
|
||||
{% endif -%}
|
||||
response.text().then((html) => {
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
document.documentElement.innerHTML = html;
|
||||
}).catch((error) => {
|
||||
console.log('failed to get html from response');
|
||||
console.log(error);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<style id="preauth-style">
|
||||
* { margin: 0; padding: 0.25em; }
|
||||
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
|
||||
html { background-color: {{ env.bg_color|e('css') }}; color: {{ env.fg_color|e('css') }}; display: table;
|
||||
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
|
||||
body { display: table-cell; vertical-align: middle; }
|
||||
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
|
||||
p { color: {{ env.error_color }}; text-align: center; }
|
||||
p { color: {{ env.error_color|e('css') }}; text-align: center; }
|
||||
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
|
||||
form div { width: 45%; min-width: 300px; }
|
||||
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{# The nonce field serves dual purpose: replay prevention AND CSRF protection.
|
||||
An attacker cannot forge a POST request without a valid nonce, which is
|
||||
generated server-side per page load and tied to the user's session. #}
|
||||
{% block content %}
|
||||
<h1>{{ env.title }}</h1>
|
||||
<p id="preauth-message">{{ message|default }}</p>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use OTPHP\TOTP;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* End-to-end functional tests for the public rate-limited access feature.
|
||||
*
|
||||
* The test environment (phpunit.dist.xml) configures:
|
||||
* PUBLIC_PATHS=/public/**
|
||||
* PUBLIC_BURST_COUNT=3, PUBLIC_BURST_TIME=60
|
||||
* PUBLIC_UPPER_COUNT=10000 (effectively unlimited for test purposes)
|
||||
*
|
||||
* @covers \App\Listener\PublicAccessListener
|
||||
* @covers \App\Service\PublicPathMatcher
|
||||
*/
|
||||
final class PublicAccessFlowTest extends WebTestCase
|
||||
{
|
||||
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
protected static function createClient(array $options = [], array $server = []): KernelBrowser
|
||||
{
|
||||
$client = parent::createClient($options, $server);
|
||||
$client->disableReboot();
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function validTotpCode(): string
|
||||
{
|
||||
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
|
||||
}
|
||||
|
||||
private function encodePayload(array $data): string
|
||||
{
|
||||
$json = json_encode($data, JSON_THROW_ON_ERROR);
|
||||
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/* ── public path accessible without auth ───────────────────────────── */
|
||||
|
||||
public function testPublicPathAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/public/some-repo');
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
// No Remote-User header for public access
|
||||
self::assertFalse($response->headers->has('Remote-User'));
|
||||
}
|
||||
|
||||
public function testPublicPathWithQuerystringAccessible(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/public/repo?tab=issues&page=2');
|
||||
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDeepPublicPathAccessible(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/public/org/repo/issues/42');
|
||||
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── non-public path requires auth ─────────────────────────────────── */
|
||||
|
||||
public function testNonPublicPathShowsLoginPage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/private/settings');
|
||||
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
self::assertSelectorExists('form#preauth-form');
|
||||
}
|
||||
|
||||
public function testRootPathShowsLoginPage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/');
|
||||
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testExactPublicPathWithoutSlashNotMatched(): void
|
||||
{
|
||||
// /public/** does NOT match /public (no trailing content)
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/public');
|
||||
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── rate limiting ─────────────────────────────────────────────────── */
|
||||
|
||||
public function testRateLimitEnforcedAfterBurstExceeded(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$client->request('GET', '/public/repo');
|
||||
self::assertSame(
|
||||
200,
|
||||
$client->getResponse()->getStatusCode(),
|
||||
"Request $i should have been allowed"
|
||||
);
|
||||
}
|
||||
|
||||
// 4th request should be rate limited
|
||||
$client->request('GET', '/public/repo');
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(429, $response->getStatusCode());
|
||||
self::assertTrue($response->headers->has('Retry-After'));
|
||||
$retryAfter = (int) $response->headers->get('Retry-After');
|
||||
self::assertGreaterThan(0, $retryAfter);
|
||||
}
|
||||
|
||||
/* ── authenticated user bypasses public rate limiter ───────────────── */
|
||||
|
||||
public function testAuthenticatedUserBypassesPublicRateLimit(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// First, exhaust the public rate limiter
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$client->request('GET', '/public/repo');
|
||||
}
|
||||
// Confirm rate limit is in effect
|
||||
$client->request('GET', '/public/repo');
|
||||
self::assertSame(429, $client->getResponse()->getStatusCode());
|
||||
|
||||
// Now log in — the cookie should let us bypass public rate limiting
|
||||
$client->getCookieJar()->clear();
|
||||
|
||||
$crawler = $client->request('GET', '/private');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/private', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||
|
||||
// Now visit a public path while authenticated — should get 200
|
||||
// (AcceptListener runs before PublicAccessListener, so the public
|
||||
// rate limiter is never consulted)
|
||||
$client->request('GET', '/public/repo');
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
// Authenticated users get Remote-User header
|
||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
/* ── 200 response has correct content type ─────────────────────────── */
|
||||
|
||||
public function testPublicAccessResponseIsPlainText(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/public/repo');
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
/* ── 429 response renders error template ───────────────────────────── */
|
||||
|
||||
public function testRateLimitedResponseRendersErrorTemplate(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Exhaust rate limit
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$client->request('GET', '/public/repo');
|
||||
}
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(429, $response->getStatusCode());
|
||||
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||
$content = $response->getContent();
|
||||
// The error template renders either teapot or too-many-requests content
|
||||
// Default test env has TEAPOT=true
|
||||
self::assertNotEmpty($content);
|
||||
}
|
||||
|
||||
/* ── security headers still applied to public responses ────────────── */
|
||||
|
||||
public function testSecurityHeadersOnPublicAccess(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/public/repo');
|
||||
|
||||
$response = $client->getResponse();
|
||||
// SecurityHeadersListener runs on all main-request responses
|
||||
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
|
||||
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\RemoteUserMode;
|
||||
use App\Utilities;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
@@ -72,6 +73,9 @@ trait TotpTestHelper
|
||||
string $errorMessage = 'Error',
|
||||
string $teapotTitle = 'Teapot',
|
||||
string $tooManyTitle = 'Too Many',
|
||||
string $remoteUserMode = 'session',
|
||||
string $remoteUserStatic = 'authenticated',
|
||||
string $remoteUserMap = '',
|
||||
): ConfigBag {
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
@@ -85,6 +89,9 @@ trait TotpTestHelper
|
||||
$errorMessage,
|
||||
$teapotTitle,
|
||||
$tooManyTitle,
|
||||
$remoteUserMode,
|
||||
$remoteUserStatic,
|
||||
$remoteUserMap,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestKernel extends AppKernel
|
||||
$container->addCompilerPass(new class () implements CompilerPassInterface {
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) {
|
||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
|
||||
if ($container->hasDefinition($poolId)) {
|
||||
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Unit\Command;
|
||||
|
||||
use App\Command\GenerateBackupCodesCommand;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use App\PersistCache;
|
||||
use App\Service\BackupCodeInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -101,8 +102,9 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
self::assertSame(0, $exit);
|
||||
}
|
||||
|
||||
public function testZeroCodesOutputsNothing(): void
|
||||
public function testZeroCodesThrowsException(): void
|
||||
{
|
||||
// count must be a positive integer — zero is rejected
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub([]),
|
||||
$this->makePersistCache()
|
||||
@@ -110,16 +112,14 @@ final class GenerateBackupCodesCommandTest extends TestCase
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$exit = $tester->execute(['count' => 0]);
|
||||
|
||||
self::assertSame(0, $exit);
|
||||
self::assertSame('', trim($tester->getDisplay()));
|
||||
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class);
|
||||
$tester->execute(['count' => 0]);
|
||||
}
|
||||
|
||||
public function testCommandNameAndDescriptionAreConfigured(): void
|
||||
{
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub([]),
|
||||
$this->makeManagerStub(['dummy']),
|
||||
$this->makePersistCache()
|
||||
);
|
||||
// configuring via the Application runs the protected configure()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\RemoteUserMode;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ConfigBagRemoteUserTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
public function testDefaultRemoteUserModeIsSession(): void
|
||||
{
|
||||
$config = $this->makeConfig();
|
||||
|
||||
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||
}
|
||||
|
||||
public function testStaticMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
||||
|
||||
self::assertSame(RemoteUserMode::Static, $config->remoteUserMode());
|
||||
self::assertSame('authenticated', $config->remoteUserStatic());
|
||||
}
|
||||
|
||||
public function testMappedMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
|
||||
|
||||
self::assertSame(RemoteUserMode::Mapped, $config->remoteUserMode());
|
||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testNoneMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'none');
|
||||
|
||||
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
|
||||
}
|
||||
|
||||
public function testInvalidModeFallsBackToSession(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
|
||||
|
||||
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
|
||||
}
|
||||
|
||||
public function testEmptyMapReturnsEmptyArray(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
|
||||
|
||||
self::assertSame([], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testMapParsesWithWhitespace(): void
|
||||
{
|
||||
$config = $this->makeConfig(
|
||||
remoteUserMode: 'mapped',
|
||||
remoteUserMap: ' alice : admin , bob : user ',
|
||||
);
|
||||
|
||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testMapIgnoresInvalidEntries(): void
|
||||
{
|
||||
$config = $this->makeConfig(
|
||||
remoteUserMode: 'mapped',
|
||||
remoteUserMap: 'alice:admin,noColon,bob:user',
|
||||
);
|
||||
|
||||
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
|
||||
}
|
||||
|
||||
public function testMapPreservesColonsInValue(): void
|
||||
{
|
||||
$config = $this->makeConfig(
|
||||
remoteUserMode: 'mapped',
|
||||
remoteUserMap: 'alice:admin:extra',
|
||||
);
|
||||
|
||||
self::assertSame(['alice' => 'admin:extra'], $config->remoteUserMap());
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ final class ConfigBagTest extends TestCase
|
||||
true,
|
||||
'Error!',
|
||||
'Teapot!',
|
||||
'Too Many!'
|
||||
'Too Many!',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertSame($clock, $config->clock());
|
||||
@@ -72,7 +75,10 @@ final class ConfigBagTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertSame('fallback-totp', $config->totpUri());
|
||||
@@ -92,7 +98,10 @@ final class ConfigBagTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
@@ -112,7 +121,10 @@ final class ConfigBagTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Listener\AcceptListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
@@ -22,9 +23,12 @@ final class AcceptListenerTest extends TestCase
|
||||
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
|
||||
{
|
||||
$listener = new AcceptListener($pool, $domainManager);
|
||||
private function makeListener(
|
||||
ArrayAdapter $pool,
|
||||
DomainManager $domainManager,
|
||||
?ConfigBag $config = null,
|
||||
): AcceptListener {
|
||||
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
|
||||
$listener->setLogger(new NullLogger());
|
||||
return $listener;
|
||||
}
|
||||
@@ -130,4 +134,131 @@ final class AcceptListenerTest extends TestCase
|
||||
// empty cookie value should not be treated as a valid session
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── Remote-User header modes ─────────────────────────────────────── */
|
||||
|
||||
public function testRemoteUserSessionModeSendsSessionId(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'session'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserStaticModeSendsFixedValue(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserMappedModeSendsMappedValue(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserMappedModeFallsBackToSessionIdWhenNotInMap(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('unknown_user');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testRemoteUserNoneModeOmitsHeader(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('alice');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener(
|
||||
$pool,
|
||||
$domainManager,
|
||||
$this->makeConfig(remoteUserMode: 'none'),
|
||||
);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertFalse($event->getResponse()->headers->has('Remote-User'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\PublicAccessListener;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PublicPathMatcher;
|
||||
use App\Tests\Support\ListenerTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for PublicAccessListener.
|
||||
*
|
||||
* @covers \App\Listener\PublicAccessListener
|
||||
*/
|
||||
final class PublicAccessListenerTest extends TestCase
|
||||
{
|
||||
use ListenerTestHelper;
|
||||
|
||||
private function makeListener(
|
||||
string $publicPaths = '',
|
||||
int $remainingTokens = 10,
|
||||
?string $authSubdomain = null,
|
||||
): PublicAccessListener {
|
||||
$pathMatcher = new PublicPathMatcher($publicPaths);
|
||||
|
||||
$domainManager = $this->createStub(DomainInterface::class);
|
||||
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
|
||||
|
||||
$listener = new PublicAccessListener(
|
||||
$pathMatcher,
|
||||
$domainManager,
|
||||
$this->makeTwig(),
|
||||
$this->makeRateLimiterFactory($remainingTokens),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
return $listener;
|
||||
}
|
||||
|
||||
private function makeEvent(Request $request): RequestEvent
|
||||
{
|
||||
return new RequestEvent(
|
||||
$this->createStub(HttpKernelInterface::class),
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── feature disabled ──────────────────────────────────────────────── */
|
||||
|
||||
public function testNoPublicPathsReturnsWithoutResponse(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '');
|
||||
|
||||
$request = Request::create('/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── non-public path ───────────────────────────────────────────────── */
|
||||
|
||||
public function testNonPublicPathReturnsWithoutResponse(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '/public/**');
|
||||
|
||||
$request = Request::create('/private', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── public path within rate limit ─────────────────────────────────── */
|
||||
|
||||
public function testPublicPathWithinRateLimitReturns200(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
|
||||
|
||||
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||
// No Remote-User header for public access
|
||||
self::assertFalse($response->headers->has('Remote-User'));
|
||||
}
|
||||
|
||||
/* ── public path rate limited ──────────────────────────────────────── */
|
||||
|
||||
public function testPublicPathOverRateLimitReturns429(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||
|
||||
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
|
||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||
self::assertTrue($response->headers->has('Retry-After'));
|
||||
}
|
||||
|
||||
public function testRateLimitedResponseContainsErrorTemplate(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||
|
||||
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$content = $event->getResponse()->getContent();
|
||||
// Default teapot template content (env.teapot is true in test helper)
|
||||
self::assertStringContainsString('teapot', $content);
|
||||
}
|
||||
|
||||
/* ── auth subdomain is never public ────────────────────────────────── */
|
||||
|
||||
public function testAuthSubdomainRequestIsSkipped(): void
|
||||
{
|
||||
$listener = $this->makeListener(
|
||||
publicPaths: '/**',
|
||||
remainingTokens: 10,
|
||||
authSubdomain: 'auth.example.com',
|
||||
);
|
||||
|
||||
// Request to auth subdomain — should NOT be treated as public
|
||||
$request = Request::create('https://auth.example.com/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── query string is ignored ───────────────────────────────────────── */
|
||||
|
||||
public function testQueryStringIsIgnoredForPathMatching(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
|
||||
|
||||
$request = Request::create('/public?foo=bar', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── domain-scoped paths ───────────────────────────────────────────── */
|
||||
|
||||
public function testDomainScopedPathMatchesCorrectHost(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||
|
||||
$request = Request::create('https://code.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDomainScopedPathDoesNotMatchOtherHost(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||
|
||||
$request = Request::create('https://other.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── wildcard matching ─────────────────────────────────────────────── */
|
||||
|
||||
public function testSingleWildcardMatching(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||
|
||||
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSingleWildcardDoesNotMatchDeepPath(): void
|
||||
{
|
||||
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||
|
||||
$request = Request::create('/public/a/b', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── 200 response includes remaining token count ───────────────────── */
|
||||
|
||||
public function testOkResponseIncludesRetryAfterHeader(): void
|
||||
{
|
||||
// The 200 response includes a Retry-After header showing remaining tokens
|
||||
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
|
||||
|
||||
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('42', $response->headers->get('Retry-After'));
|
||||
}
|
||||
}
|
||||
@@ -171,29 +171,26 @@ final class DomainManagerTest extends TestCase
|
||||
|
||||
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
|
||||
// com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.com.au');
|
||||
self::assertSame('com.au', $manager->authBase());
|
||||
self::assertSame('example.com.au', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.com.au'));
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
}
|
||||
|
||||
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
|
||||
// co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.co.jp');
|
||||
self::assertSame('co.jp', $manager->authBase());
|
||||
self::assertSame('example.co.jp', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
|
||||
}
|
||||
|
||||
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
|
||||
// com.br: TLD table has br => [com,...], so *.com.br IS multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.com.br');
|
||||
self::assertSame('com.br', $manager->authBase());
|
||||
self::assertSame('example.com.br', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.com.br'));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\PublicPathMatcher;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for PublicPathMatcher — path pattern parsing and matching.
|
||||
*
|
||||
* @covers \App\Service\PublicPathMatcher
|
||||
*/
|
||||
final class PublicPathMatcherTest extends TestCase
|
||||
{
|
||||
/* ── empty / disabled ──────────────────────────────────────────────── */
|
||||
|
||||
public function testEmptyStringResultsInNoPatterns(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('');
|
||||
self::assertTrue($matcher->isEmpty());
|
||||
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||
}
|
||||
|
||||
public function testWhitespaceOnlyStringResultsInNoPatterns(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher(' ');
|
||||
self::assertTrue($matcher->isEmpty());
|
||||
}
|
||||
|
||||
/* ── exact path matching ───────────────────────────────────────────── */
|
||||
|
||||
public function testExactPathMatch(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public');
|
||||
self::assertTrue($matcher->matches('example.com', '/public'));
|
||||
}
|
||||
|
||||
public function testExactPathDoesNotMatchSubpath(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public');
|
||||
self::assertFalse($matcher->matches('example.com', '/public/'));
|
||||
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
||||
}
|
||||
|
||||
public function testExactPathDoesNotMatchDifferentPath(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public');
|
||||
self::assertFalse($matcher->matches('example.com', '/private'));
|
||||
self::assertFalse($matcher->matches('example.com', '/'));
|
||||
}
|
||||
|
||||
/* ── single wildcard * ─────────────────────────────────────────────── */
|
||||
|
||||
public function testSingleWildcardMatchesOneSegment(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/*');
|
||||
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||
self::assertTrue($matcher->matches('example.com', '/public/xyz'));
|
||||
}
|
||||
|
||||
public function testSingleWildcardDoesNotMatchBasePath(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/*');
|
||||
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||
}
|
||||
|
||||
public function testSingleWildcardDoesNotCrossSegments(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/*');
|
||||
self::assertFalse($matcher->matches('example.com', '/public/a/b'));
|
||||
}
|
||||
|
||||
public function testSingleWildcardDoesNotMatchEmptySegment(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/*');
|
||||
self::assertFalse($matcher->matches('example.com', '/public/'));
|
||||
}
|
||||
|
||||
/* ── double wildcard ** ────────────────────────────────────────────── */
|
||||
|
||||
public function testDoubleWildcardMatchesMultipleSegments(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/**');
|
||||
self::assertTrue($matcher->matches('example.com', '/public/a'));
|
||||
self::assertTrue($matcher->matches('example.com', '/public/a/b/c'));
|
||||
}
|
||||
|
||||
public function testDoubleWildcardDoesNotMatchBasePath(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/**');
|
||||
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||
}
|
||||
|
||||
public function testDoubleWildcardMatchesTrailingSlash(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/**');
|
||||
self::assertTrue($matcher->matches('example.com', '/public/'));
|
||||
}
|
||||
|
||||
/* ── mid-path wildcards ────────────────────────────────────────────── */
|
||||
|
||||
public function testMidPathSingleWildcard(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/api/*/status');
|
||||
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
||||
self::assertTrue($matcher->matches('example.com', '/api/v2/status'));
|
||||
self::assertFalse($matcher->matches('example.com', '/api/v1/v2/status'));
|
||||
self::assertFalse($matcher->matches('example.com', '/api/status'));
|
||||
}
|
||||
|
||||
public function testMidPathDoubleWildcard(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/api/**/status');
|
||||
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
||||
self::assertTrue($matcher->matches('example.com', '/api/v1/v2/status'));
|
||||
self::assertTrue($matcher->matches('example.com', '/api/status'));
|
||||
}
|
||||
|
||||
/* ── multiple patterns ─────────────────────────────────────────────── */
|
||||
|
||||
public function testMultiplePatternsCommaSeparated(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/**,/api/status,/health');
|
||||
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||
self::assertTrue($matcher->matches('example.com', '/api/status'));
|
||||
self::assertTrue($matcher->matches('example.com', '/health'));
|
||||
self::assertFalse($matcher->matches('example.com', '/private'));
|
||||
}
|
||||
|
||||
public function testMultiplePatternsWithWhitespace(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/**, /api/status, /health');
|
||||
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||
self::assertTrue($matcher->matches('example.com', '/api/status'));
|
||||
self::assertTrue($matcher->matches('example.com', '/health'));
|
||||
}
|
||||
|
||||
public function testEmptySegmentsInCommaListAreIgnored(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public,,/health,');
|
||||
self::assertFalse($matcher->isEmpty());
|
||||
self::assertTrue($matcher->matches('example.com', '/public'));
|
||||
self::assertTrue($matcher->matches('example.com', '/health'));
|
||||
}
|
||||
|
||||
/* ── domain-prefixed patterns ──────────────────────────────────────── */
|
||||
|
||||
public function testDomainPrefixedPatternMatchesOnThatHost(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||
}
|
||||
|
||||
public function testDomainPrefixedPatternDoesNotMatchOtherHost(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
||||
self::assertFalse($matcher->matches('other.example.com', '/public/repo'));
|
||||
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
||||
}
|
||||
|
||||
public function testPathWithoutDomainPrefixMatchesAnyHost(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/public/**');
|
||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||
self::assertTrue($matcher->matches('other.example.com', '/public/repo'));
|
||||
self::assertTrue($matcher->matches('localhost', '/public/repo'));
|
||||
}
|
||||
|
||||
public function testMixedDomainPrefixedAndPlainPatterns(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/health,code.example.com/public/**');
|
||||
self::assertTrue($matcher->matches('any.host', '/health'));
|
||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||
self::assertFalse($matcher->matches('other.host', '/public/repo'));
|
||||
}
|
||||
|
||||
public function testDomainPrefixIsCaseInsensitive(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('Code.Example.COM/public/**');
|
||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||
self::assertTrue($matcher->matches('CODE.EXAMPLE.COM', '/public/repo'));
|
||||
}
|
||||
|
||||
/* ── invalid patterns ──────────────────────────────────────────────── */
|
||||
|
||||
public function testPatternWithoutLeadingSlashIsIgnored(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('public');
|
||||
self::assertTrue($matcher->isEmpty());
|
||||
}
|
||||
|
||||
public function testInvalidPatternAmongValidOnesIsIgnored(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('invalid,/public');
|
||||
self::assertFalse($matcher->isEmpty());
|
||||
self::assertTrue($matcher->matches('example.com', '/public'));
|
||||
}
|
||||
|
||||
/* ── special regex characters in paths ─────────────────────────────── */
|
||||
|
||||
public function testSpecialRegexCharactersAreEscaped(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/path.with.dots');
|
||||
self::assertTrue($matcher->matches('example.com', '/path.with.dots'));
|
||||
self::assertFalse($matcher->matches('example.com', '/pathXwithXdots'));
|
||||
}
|
||||
|
||||
public function testPlusCharacterIsLiteral(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/a+b');
|
||||
self::assertTrue($matcher->matches('example.com', '/a+b'));
|
||||
self::assertFalse($matcher->matches('example.com', '/aaab'));
|
||||
}
|
||||
|
||||
/* ── root path ─────────────────────────────────────────────────────── */
|
||||
|
||||
public function testRootPathMatch(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/');
|
||||
self::assertTrue($matcher->matches('example.com', '/'));
|
||||
self::assertFalse($matcher->matches('example.com', '/anything'));
|
||||
}
|
||||
|
||||
public function testWildcardAtRoot(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/*');
|
||||
self::assertTrue($matcher->matches('example.com', '/anything'));
|
||||
self::assertFalse($matcher->matches('example.com', '/a/b'));
|
||||
self::assertFalse($matcher->matches('example.com', '/'));
|
||||
}
|
||||
|
||||
public function testDoubleWildcardAtRoot(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('/**');
|
||||
self::assertTrue($matcher->matches('example.com', '/'));
|
||||
self::assertTrue($matcher->matches('example.com', '/anything'));
|
||||
self::assertTrue($matcher->matches('example.com', '/a/b/c'));
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,10 @@ final class GetTotpTraitTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
@@ -100,12 +103,15 @@ final class GetTotpTraitTest extends TestCase
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
'Too Many',
|
||||
'session',
|
||||
'authenticated',
|
||||
'',
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
$this->expectException(HttpException::class);
|
||||
$this->expectExceptionMessage('Internal Server Exception');
|
||||
$this->expectExceptionMessage('Internal Server Error');
|
||||
$obj->publicGetTotp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use App\Trait\StringTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class StringTraitTest extends TestCase
|
||||
{
|
||||
use StringTrait;
|
||||
use TotpTestHelper;
|
||||
|
||||
public function testMakeCacheKeySanitizesInvalidChars(): void
|
||||
{
|
||||
@@ -67,4 +69,50 @@ final class StringTraitTest extends TestCase
|
||||
$result = $this->makeCacheKey('a🎉b');
|
||||
self::assertSame('a_b', $result);
|
||||
}
|
||||
|
||||
/* ── authSuccessResponse ──────────────────────────────────────────── */
|
||||
|
||||
public function testAuthSuccessResponseSessionMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'session');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('hi alice', $response->getContent());
|
||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseStaticMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('hi alice', $response->getContent());
|
||||
self::assertSame('authenticated', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseMappedMode(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('admin', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseMappedModeFallback(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
|
||||
$response = $this->authSuccessResponse('unknown', $config);
|
||||
|
||||
self::assertSame('unknown', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testAuthSuccessResponseNoneModeOmitsHeader(): void
|
||||
{
|
||||
$config = $this->makeConfig(remoteUserMode: 'none');
|
||||
$response = $this->authSuccessResponse('alice', $config);
|
||||
|
||||
self::assertSame('hi alice', $response->getContent());
|
||||
self::assertFalse($response->headers->has('Remote-User'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user