Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0bda8aeec | ||
|
|
cb378e20bc | ||
|
|
6b5a711fa9 | ||
|
|
95ab77db2a | ||
|
|
d6bcbf661e | ||
|
|
de2a382cbf | ||
|
|
12ba6cde7b | ||
|
|
6c5a7c98e8 | ||
|
|
4d314bcb28 | ||
|
|
890cc225ef | ||
|
|
3c1253ee45 | ||
|
|
70bf811b1d | ||
|
|
3269151e9b | ||
|
|
1da2188bdf | ||
|
|
7cf7e04d17 | ||
|
|
0813323ac2 | ||
|
|
9114cfd96f | ||
|
|
43e9b7136e | ||
|
|
38124ef66c | ||
|
|
6ed1ab26f1 | ||
|
|
a0dc1a6049 | ||
|
|
3d28485921 | ||
|
|
27394ae555 | ||
|
|
c235aad941 | ||
|
|
b61400085a | ||
|
|
4a543f45ca | ||
|
|
4cec5a5963 | ||
|
|
c3fbb12842 | ||
|
|
ac817649ab | ||
|
|
1c4c289d81 | ||
|
|
b36aabb8a3 | ||
|
|
f486ab7481 | ||
|
|
a73d039e10 | ||
|
|
102b9f3e78 |
@@ -0,0 +1,12 @@
|
||||
.git/
|
||||
.gitignore
|
||||
var/
|
||||
vendor/
|
||||
tests/
|
||||
.phpunit.cache/
|
||||
docs/
|
||||
*.md
|
||||
.env
|
||||
.env.test
|
||||
.env.local
|
||||
composer.phar
|
||||
@@ -1,3 +1,20 @@
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[Caddyfile]
|
||||
indent_style = tab
|
||||
|
||||
[{compose.yaml,compose.*.yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# required, if missing will generate random values
|
||||
# encryption key used to store sessions (static random bytes) in base64
|
||||
PREAUTH_KEY=''
|
||||
# TOTP (RFC 6238) secret/token (static random bytes) in base32
|
||||
PREAUTH_TOKEN=''
|
||||
|
||||
# optional, change time-to-live, subdomain, default-redirect, text or colors
|
||||
# how long a session lasts (in minutes): 43200 is 30 days
|
||||
PREAUTH_TTL=43200
|
||||
PREAUTH_SUBDOMAIN='preauth'
|
||||
PREAUTH_SEND_TO='https://secure.example.com/'
|
||||
PREAUTH_BACKGROUND='#029386'
|
||||
PREAUTH_FOREGROUND='#ffffff'
|
||||
PREAUTH_TITLE='Pre-Authentication System'
|
||||
PREAUTH_ID_NAME='Session ID'
|
||||
PREAUTH_TOKEN_NAME='Authentication Token'
|
||||
PREAUTH_SUBMIT_NAME='Submit'
|
||||
# how many consecutive failed login attempts before we block them (a remote-ip)
|
||||
PREAUTH_RATE_LIMIT=4
|
||||
# maximum time between failed login attempts to still be consecutive (in minutes): 360 is 6 hours
|
||||
PREAUTH_RATE_TIMEOUT=360
|
||||
# how long after last failed login will they be blocked (in minutes): 1440 is 24 hours
|
||||
PREAUTH_RATE_BLOCKED=1440
|
||||
# what do we show when they get rate-limited
|
||||
PREAUTH_DENIED_CODE=418
|
||||
PREAUTH_DENIED_TITLE="I'm a teapot"
|
||||
PREAUTH_DENIED_MESSAGE='I refuse to brew coffee.'
|
||||
# alternatively, you could use a more standard response
|
||||
#PREAUTH_DENIED_CODE=429
|
||||
#PREAUTH_DENIED_TITLE='Too Many Requests'
|
||||
#PREAUTH_DENIED_MESSAGE='Try again later.'
|
||||
|
||||
# who owns the session files
|
||||
# permissions of the volume must match
|
||||
USER_ID=1000
|
||||
GROUP_ID=1000
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
APP_ENV=test
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=test_secret_key_change_me
|
||||
# fixed TOTP secret (JBSWY3DPEHPK3PXP) so functional tests can compute valid codes
|
||||
TOTP_URI='otpauth://totp/Test-TOTP?secret=JBSWY3DPEHPK3PXP'
|
||||
COOKIE_TTL=2592000
|
||||
SUBDOMAIN_REDIRECT=0
|
||||
AUTH_SUBDOMAIN=''
|
||||
IP_TTL=0
|
||||
TEAPOT=1
|
||||
BURST_COUNT=10
|
||||
BURST_TIME=30
|
||||
UPPER_COUNT=100
|
||||
UPPER_TIME=3600
|
||||
TITLE='Pre-Authentication System'
|
||||
BG_COLOR='#029386'
|
||||
FG_COLOR='#ffffff'
|
||||
ERROR_COLOR='#ffb16d'
|
||||
ID_NAME='Session ID'
|
||||
TOKEN_NAME='Authentication Token'
|
||||
SUBMIT_NAME='Submit'
|
||||
ERROR_MESSAGE='Unsuccessful login attempt'
|
||||
TEAPOT_TITLE="I'm a teapot"
|
||||
TEAPOT_MESSAGE='I refuse to brew coffee'
|
||||
TOO_MANY_TITLE='Too many requests'
|
||||
TOO_MANY_MESSAGE='Try again later'
|
||||
SHELL_VERBOSITY=0
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Push Develop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_TARGET }}:develop
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Push Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_TARGET }}:latest
|
||||
${{ vars.DOCKERHUB_TARGET }}:${{ github.ref_name }}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Sync GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "Andrew Sync"
|
||||
git config --global user.email "sync@digitaladapt.com"
|
||||
|
||||
- name: Add GitHub Remote
|
||||
env:
|
||||
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"
|
||||
|
||||
- name: Push Current Branch
|
||||
run: |
|
||||
git push github HEAD:${GITHUB_REF_NAME}
|
||||
|
||||
- name: Push Tags
|
||||
run: |
|
||||
git push github --tags
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.5'
|
||||
extensions: apcu, mbstring
|
||||
coverage: xdebug
|
||||
ini-values: apc.enable_cli=1
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run php-cs-fixer
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
|
||||
- name: Run tests
|
||||
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/.idea/
|
||||
###> symfony/framework-bundle ###
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.cache/
|
||||
/bin/.phpunit.result.cache
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> project-specific ###
|
||||
/config/reference.php
|
||||
###< project-specific ###
|
||||
|
||||
###> friendsofphp/php-cs-fixer ###
|
||||
/.php-cs-fixer.php
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
$finder = (new PhpCsFixer\Finder())
|
||||
->in(__DIR__)
|
||||
->exclude('var')
|
||||
->exclude('vendor')
|
||||
->notPath([
|
||||
'config/bundles.php',
|
||||
'config/reference.php',
|
||||
])
|
||||
;
|
||||
|
||||
return (new PhpCsFixer\Config())
|
||||
->setRules([
|
||||
'@PSR12' => true,
|
||||
])
|
||||
->setFinder($finder)
|
||||
;
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
header("http/1.1 {$env->getDeniedCode()} {$env->getDeniedTitle()}", true, $env->getDeniedCode());
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo $env->getTitle(); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Begin Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
|
||||
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
|
||||
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
|
||||
<!-- End Icons -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
}
|
||||
html {
|
||||
background-color: <?php echo $env->getColor(); ?>;
|
||||
color: <?php echo $env->getTextColor(); ?>;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
font-size: 1.5em;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><?php echo $env->getTitle(); ?></h1>
|
||||
<h2><?php echo $env->getDeniedTitle(); ?></h2>
|
||||
<p><?php echo $env->getDeniedMessage(); ?></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo $env->getTitle(); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Begin Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
|
||||
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
|
||||
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
|
||||
<!-- End Icons -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
}
|
||||
html {
|
||||
background-color: <?php echo $env->getColor(); ?>;
|
||||
color: <?php echo $env->getTextColor(); ?>;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
font-size: 1.5em;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><?php echo $env->getTitle(); ?></h1>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p>Review the logs for more details.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +1,7 @@
|
||||
# allow the pre-auth page to work
|
||||
preauth.example.com {
|
||||
reverse_proxy preauth:9000 {
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
header_down -X-Powered-By
|
||||
rewrite /preauth.php
|
||||
transport fastcgi {
|
||||
root /preauth/
|
||||
split .php
|
||||
}
|
||||
}
|
||||
http://
|
||||
root public/
|
||||
rewrite index.php
|
||||
php {
|
||||
root /app/public
|
||||
worker index.php
|
||||
}
|
||||
|
||||
# if using caddy v2.9.x+ you can use this snippet
|
||||
# snippet to put the pre-auth system in front any service easily
|
||||
(preauth) {
|
||||
reverse_proxy {args[0]} preauth:9000 {
|
||||
method GET
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
header_down -X-Powered-By
|
||||
rewrite /preauth.php
|
||||
transport fastcgi {
|
||||
root /preauth/
|
||||
split .php
|
||||
}
|
||||
@preauth_ok status 2xx
|
||||
handle_response @preauth_ok {
|
||||
copy_response_headers {
|
||||
include Set-Cookie Location
|
||||
}
|
||||
{block}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# snippet usage: this will cause preauth to restrict access to
|
||||
# https://protected.example.com/secure
|
||||
protected.example.com {
|
||||
import preauth /secure {
|
||||
reverse_proxy protected-service:9000
|
||||
}
|
||||
reverse_proxy exposed-service:9000
|
||||
}
|
||||
|
||||
|
||||
+57
-22
@@ -1,34 +1,69 @@
|
||||
FROM php:8.4-fpm-alpine
|
||||
# use build image, to simplify final image
|
||||
FROM php:8.5-trixie AS build
|
||||
|
||||
# get compose so we can install our php dependencies
|
||||
# install APCu and composer
|
||||
RUN pecl install apcu && \
|
||||
docker-php-ext-enable apcu
|
||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y unzip git
|
||||
|
||||
# app is stored here
|
||||
RUN mkdir /preauth
|
||||
WORKDIR /preauth
|
||||
COPY . /preauth/
|
||||
# symfony required environment variables
|
||||
ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
|
||||
# add php config for rate limit monitoring
|
||||
RUN mkdir -p /usr/local/etc/php/conf.d
|
||||
COPY preauth-php.ini /usr/local/etc/php/conf.d/preauth-php.ini
|
||||
# load application into build image
|
||||
RUN mkdir -p /data/preauth
|
||||
RUN mkdir -p /app/bin
|
||||
WORKDIR /app
|
||||
COPY ./bin/console /app/bin/console
|
||||
COPY ./config /app/config
|
||||
COPY ./public /app/public
|
||||
COPY ./src /app/src
|
||||
COPY ./templates /app/templates
|
||||
COPY ./composer.json /app/composer.json
|
||||
COPY ./composer.lock /app/composer.lock
|
||||
COPY ./symfony.lock /app/symfony.lock
|
||||
|
||||
# login sessions are stored here
|
||||
RUN mkdir -p /tmp/data/sessions
|
||||
# install application dependencies
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
RUN composer dump-env prod --empty
|
||||
|
||||
# rate limit monitoring information is stored here
|
||||
RUN mkdir -p /tmp/data/monitor
|
||||
# start creating final image
|
||||
FROM dunglas/frankenphp:php8.5-trixie
|
||||
|
||||
# fcgi command for the healthcheck
|
||||
RUN apk add fcgi
|
||||
# install APCu
|
||||
RUN pecl install apcu && \
|
||||
docker-php-ext-enable apcu
|
||||
|
||||
# install our php dependencies
|
||||
RUN composer install
|
||||
# symfony required environment variables
|
||||
ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
|
||||
EXPOSE 9000
|
||||
# load application into final image
|
||||
WORKDIR /app
|
||||
COPY --from=build /data/preauth /data/preauth
|
||||
COPY --from=build /app /app
|
||||
|
||||
HEALTHCHECK --interval=5m --retries=3 --start-interval=5s --start-period=50s --timeout=5s \
|
||||
CMD SCRIPT_NAME=/health.php SCRIPT_FILENAME=/preauth/health.php REQUEST_METHOD=GET \
|
||||
cgi-fcgi -bind -connect localhost:9000 | grep 'online' || exit 1
|
||||
# configure container
|
||||
COPY ./Caddyfile /etc/frankenphp/Caddyfile
|
||||
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
|
||||
RUN echo 'expose_php = off' > $PHP_INI_DIR/conf.d/restrict.ini
|
||||
# console needs apc to manage cache
|
||||
RUN echo 'apc.enable_cli = on' > $PHP_INI_DIR/conf.d/console.ini
|
||||
|
||||
ENTRYPOINT ["/preauth/init.sh"]
|
||||
# app uses var folder for cache storage
|
||||
VOLUME ["/config", "/data"]
|
||||
|
||||
# runs http on standard port
|
||||
EXPOSE 80
|
||||
|
||||
# healthcheck
|
||||
HEALTHCHECK --interval=5m \
|
||||
--retries=3 \
|
||||
--start-interval=1s \
|
||||
--start-period=10s \
|
||||
--timeout=2s \
|
||||
CMD curl http://localhost || exit 1
|
||||
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
# Preauth — Project Roadmap
|
||||
|
||||
## Project Overview
|
||||
|
||||
Preauth is a pre-authentication gate for self-hosted services. It sits
|
||||
between a reverse proxy (Caddy's `forward_auth`) and your web service,
|
||||
requiring a TOTP code (or backup code) before traffic ever reaches the
|
||||
protected application. It is **not** a replacement for the service's own
|
||||
authentication — it's a gate that prevents outsiders from even seeing
|
||||
what service is running.
|
||||
|
||||
- **Location:** `projects/preauth/`
|
||||
- **Framework:** Symfony 7.4 (PHP ≥ 8.4)
|
||||
- **Serving:** FrankenPHP (Docker image)
|
||||
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
|
||||
- **Auth:** TOTP (single secret) + single-use backup codes
|
||||
- **Production status:** Running in production since June 2024
|
||||
|
||||
### Current Production Use
|
||||
|
||||
| Service | Purpose |
|
||||
|-------------|--------------------------------------------------|
|
||||
| Bitwarden | Password manager — always accessible, invisible to the world |
|
||||
| Microbin | Sharing text blobs and small files across devices |
|
||||
| Gitea | Code hosting — some DNS configs must be public |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
Client → Caddy → forward_auth → Preauth listeners (priority order) → 200/401/418
|
||||
```
|
||||
|
||||
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
|
||||
If found → `200 OK` + `Remote-User` header → Caddy proxies to backend.
|
||||
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
|
||||
valid IP-based session. If found → `200 OK` + `Remote-User`.
|
||||
3. **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
|
||||
`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
|
||||
set a response, either redirects to auth subdomain (central auth) or
|
||||
renders the Twig login page with a fresh nonce.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **No controllers** — Entirely event-listener-driven. Clean separation
|
||||
of concerns, each listener handles one stage of the auth flow.
|
||||
- **Dual-layer cache** — APCu for fast in-memory lookups, file-based
|
||||
storage for persistence across container restarts. `MonitorCacheKeys`
|
||||
wraps the PSR-6 pool to track key changes for efficient persistence
|
||||
(only write what changed).
|
||||
- **`__Host-` prefixed cookies** — `SameSite=Strict`, `Secure`,
|
||||
`HttpOnly`. Central auth mode uses a separate `__Http-Domain-Preauth`
|
||||
cookie name (domain-scoped, no `__Host-` prefix).
|
||||
- **Nonce system** — 15-byte random nonces, single-use, 120s TTL, with
|
||||
retry-on-collision (up to 3 attempts).
|
||||
- **TOTP with 10-second leeway** — Accommodates clock drift.
|
||||
- **Backup codes** — Case-insensitive alphanumeric, single-use, stored
|
||||
in cache with year-2999 expiry. Generated via console command.
|
||||
- **Domain awareness** — `DomainManager` handles multi-part TLDs
|
||||
(`.co.uk`, `.com.au`, etc.) with a built-in TLD lookup table.
|
||||
- **Interfaces** — `LoginInterface`, `DomainInterface`,
|
||||
`BackupCodeInterface` extracted to support testing (mockable).
|
||||
|
||||
---
|
||||
|
||||
## Test Suite Status
|
||||
|
||||
### Current Results
|
||||
|
||||
| Metric | Value |
|
||||
|--------------|--------------------------------|
|
||||
| **Tests** | 222 |
|
||||
| **Assertions** | 469 |
|
||||
| **Pass** | 222 (100%) |
|
||||
| **Fail** | 0 |
|
||||
| **Errors** | 0 |
|
||||
| **Warnings** | 0 |
|
||||
| **Time** | ~0.56s (without coverage) |
|
||||
| | ~1.31s (with coverage) |
|
||||
|
||||
### Code Coverage
|
||||
|
||||
| Metric | Percentage |
|
||||
|----------|---------------------|
|
||||
| **Lines** | **100.00%** (442/442) |
|
||||
| **Methods** | **100.00%** (83/83) |
|
||||
| **Classes** | **100.00%** (21/21) |
|
||||
|
||||
Every class, method, and line in `src/` is covered.
|
||||
|
||||
### Source → Test Mapping
|
||||
|
||||
| Source File | Test File | Type |
|
||||
|------------------------------------------|----------------------------------------------------|----------|
|
||||
| `Clock.php` | `Unit/ClockTest.php` | Unit |
|
||||
| `ConfigBag.php` | `Unit/ConfigBagTest.php` | Unit |
|
||||
| `Kernel.php` | (covered via functional tests) | Functional |
|
||||
| `MonitorCacheKeys.php` | `Unit/MonitorCacheKeysTest.php` | Unit |
|
||||
| `PersistCache.php` | `Unit/PersistCacheTest.php` | Unit |
|
||||
| `Utilities.php` | `Unit/UtilitiesTest.php` | Unit |
|
||||
| `Command/GenerateBackupCodesCommand.php` | `Unit/Command/GenerateBackupCodesCommandTest.php` | Unit |
|
||||
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
|
||||
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.php` | Unit |
|
||||
| `Listener/AcceptListener.php` | `Unit/Listener/AcceptListenerTest.php` | Unit |
|
||||
| `Listener/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/LoginManager.php` | `Unit/Service/LoginManagerTest.php` | Unit |
|
||||
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.php` | Unit |
|
||||
| `Trait/GetTotpTrait.php` | `Unit/Trait/GetTotpTraitTest.php` | Unit |
|
||||
| `Trait/HasLoggerTrait.php` | `Unit/Trait/HasLoggerTraitTest.php` | Unit |
|
||||
| `Trait/MakeNonceTrait.php` | `Unit/Trait/MakeNonceTraitTest.php` | Unit |
|
||||
| `Trait/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
|
||||
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
|
||||
|
||||
### Test Quality Assessment
|
||||
|
||||
**Strengths:**
|
||||
- **100% coverage** — every line, method, and class.
|
||||
- **Well-structured test hierarchy** — Unit tests per class, functional
|
||||
tests for the full HTTP kernel flow. Two support traits
|
||||
(`TotpTestHelper`, `ListenerTestHelper`) provide reusable fixtures
|
||||
(frozen clock, deterministic TOTP, Twig environment, mock rate
|
||||
limiters).
|
||||
- **Edge cases well-covered** — ULID collision handling, nonce collision
|
||||
retries, spent nonces, invalid payloads (bad base64, non-object JSON,
|
||||
arrays, null, booleans), empty/whitespace fields, field truncation,
|
||||
multibyte characters in cache keys, multi-part TLD domain matching,
|
||||
cookie pruning on invalid sessions.
|
||||
- **Both positive and negative paths** — Every listener tests both
|
||||
success and failure scenarios.
|
||||
- **Security-conscious testing** — Backup code single-use enforcement,
|
||||
case-insensitivity, character stripping, rate limit teapot vs.
|
||||
too-many-requests, return URL validation (prevents open redirect),
|
||||
cookie security attributes.
|
||||
- **Realistic functional tests** — `AuthenticationFlowTest` goes through
|
||||
the actual Symfony kernel: fetches nonces from rendered HTML, submits
|
||||
TOTP codes, verifies cookies are set, tests the full login →
|
||||
authenticated access cycle.
|
||||
- **Smart test infrastructure** — `KernelBrowser::disableReboot()` used
|
||||
in functional tests so nonces persist across requests (matching
|
||||
production APCu behavior).
|
||||
|
||||
**Status: Test suite goal is met.** 222 tests, 100% coverage, all passing.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1 — Public but Rate-Limited Access ✦
|
||||
|
||||
**Goal:** Allow select services to be publicly accessible (no TOTP
|
||||
required) but with aggressive per-IP rate limiting to prevent bot
|
||||
traffic from overwhelming the server.
|
||||
|
||||
**Context:** The user previously made Gitea semi-public (view but no
|
||||
login), but bot traffic slowed the server and consumed all household
|
||||
bandwidth, forcing it back to fully private. The solution isn't more
|
||||
authentication — it's bandwidth/resource protection for public-facing
|
||||
services.
|
||||
|
||||
**Design:**
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
- 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
|
||||
}
|
||||
```
|
||||
|
||||
- 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).
|
||||
|
||||
- [ ] 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
|
||||
|
||||
### Phase 2 — Session Management & Audit
|
||||
|
||||
**Goal:** Give visibility into who has access and when it was granted.
|
||||
|
||||
- [ ] **Active sessions view** — Console command or simple API endpoint
|
||||
to list active sessions (cookie-based and IP-based), showing:
|
||||
- Session ID / username
|
||||
- IP address
|
||||
- First auth timestamp
|
||||
- Last seen timestamp
|
||||
- Scope (cookie vs. IP)
|
||||
- [ ] **Session revocation** — Console command to revoke a specific
|
||||
session by ID or revoke all sessions for an IP.
|
||||
- [ ] **Audit log** — Log every successful and failed authentication
|
||||
attempt to a persistent store (file-based JSONL, similar to the email
|
||||
integration's audit log):
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-01-15T14:23:01Z",
|
||||
"ip": "192.168.1.50",
|
||||
"action": "login_success",
|
||||
"username": "mom",
|
||||
"method": "totp"
|
||||
}
|
||||
```
|
||||
- [ ] Tests for all new commands and endpoints
|
||||
|
||||
### Phase 2b — Backup Code System Completion
|
||||
|
||||
**Goal:** Finish the backup code system — the core logic is solid but
|
||||
the management surface is incomplete.
|
||||
|
||||
**What already exists:**
|
||||
- ✅ `BackupCodeManager::generate()` — Creates codes, saves to cache
|
||||
with year-2999 expiry
|
||||
- ✅ `BackupCodeManager::expire()` — Deletes all `backup_` prefixed
|
||||
keys from cache
|
||||
- ✅ `BackupCodeManager::verifyAndConsume()` — Validates and marks code
|
||||
as used (sets value to `false`, keeps the key for audit trail)
|
||||
- ✅ `app:generate-backup-codes [count]` console command
|
||||
- ✅ Tests for all of the above (100% coverage)
|
||||
|
||||
**What's missing:**
|
||||
|
||||
- [ ] **`app:list-backup-codes` command** — Show backup code status:
|
||||
- Total codes generated
|
||||
- How many are still valid (unused)
|
||||
- How many have been spent (and optionally when)
|
||||
- Output format: table with status column (✅ valid / ⛔ used)
|
||||
- Note: spent codes are kept in cache with value `false`, so we can
|
||||
distinguish "used" from "never existed" — this is good design
|
||||
|
||||
- [ ] **`app:expire-backup-codes` command** — Wrap the existing
|
||||
`BackupCodeManager::expire()` method in a console command. Should:
|
||||
- Show how many codes are being expired before confirmation
|
||||
- Support `--force` flag to skip confirmation prompt
|
||||
- Call `persistCache->boot()` and `persistCache->persist()` like the
|
||||
generate command does (since `Kernel::terminate()` doesn't run in
|
||||
CLI)
|
||||
|
||||
- [ ] **Notification on backup code use** — When
|
||||
`verifyAndConsume()` consumes a backup code, fire a notification
|
||||
through configurable channels:
|
||||
- Discord webhook (we already have the `discord.sh` infrastructure)
|
||||
- ntfy
|
||||
- Email (once email integration is available)
|
||||
- Webhook (generic HTTP POST for future integrations)
|
||||
- Config variables:
|
||||
- `BACKUP_CODE_NOTIFY=discord,ntfy` — comma-separated channels
|
||||
- `BACKUP_CODE_NOTIFY_WEBHOOK=''` — generic webhook URL
|
||||
- Message should include: timestamp, IP address, username, and how
|
||||
many valid codes remain
|
||||
- Architecture: `BackupCodeManager` dispatches an event
|
||||
(e.g. `BackupCodeUsedEvent`) after consuming a code. A listener
|
||||
handles the notification dispatch. This keeps the notification
|
||||
logic out of the backup code manager itself.
|
||||
|
||||
- [ ] **Low-codes warning** — If backup codes fall below a threshold
|
||||
(e.g. 3 remaining), include a warning in the notification and/or
|
||||
surface it in the `list-backup-codes` command output
|
||||
|
||||
- [ ] Tests for all new commands and notification dispatch
|
||||
|
||||
### Phase 2c — Passkey Authentication
|
||||
|
||||
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
|
||||
authentication method alongside TOTP and backup codes.
|
||||
|
||||
**Context:** Passkeys are the modern standard for passwordless auth.
|
||||
They're phishing-resistant (domain-bound), use biometrics or device
|
||||
PINs, and are significantly more user-friendly than typing 6-digit
|
||||
codes. For a pre-auth gate that friends and family use, passkeys would
|
||||
be a major UX improvement — especially for non-technical users who
|
||||
struggle with TOTP apps.
|
||||
|
||||
**Design considerations:**
|
||||
|
||||
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
|
||||
secret shared with all devices), each device registers its own
|
||||
passkey. This is actually better for a family-use gate — you can
|
||||
register mom's phone separately from dad's laptop.
|
||||
|
||||
- WebAuthn requires a **challenge-response flow**:
|
||||
1. Client requests a challenge (preauth generates and stores a
|
||||
challenge nonce, similar to the existing nonce system)
|
||||
2. Browser prompts for biometric/PIN, creates a signed assertion
|
||||
3. Server verifies the assertion against the registered credential
|
||||
|
||||
- This is a **two-step flow** unlike TOTP's single-step, which means
|
||||
the login page JS and `LoginListener` need to handle an additional
|
||||
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
|
||||
is a good foundation — extend it with a "use passkey" button that
|
||||
initiates the `navigator.credentials.get()` flow.
|
||||
|
||||
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
|
||||
Symfony bundle available). Would add registration ceremony (console
|
||||
command or initial-setup flow to register a passkey).
|
||||
|
||||
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
||||
7.4 and FrankenPHP
|
||||
- [ ] Design passkey registration flow (console command? first-visit
|
||||
setup? separate registration endpoint?)
|
||||
- [ ] Implement challenge generation and storage (extend existing
|
||||
nonce/cache infrastructure)
|
||||
- [ ] Implement assertion verification in a new `PasskeyManager`
|
||||
service (implements a shared `AuthMethodInterface`?)
|
||||
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
|
||||
- [ ] Handle multiple registered passkeys (per-device)
|
||||
- [ ] Console command: `app:list-passkeys` — show registered devices
|
||||
- [ ] Console command: `app:remove-passkey` — revoke a passkey
|
||||
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
|
||||
- [ ] Tests for registration, authentication, and revocation
|
||||
- [ ] Consider: should passkeys be a *replacement* for TOTP or an
|
||||
*alternative*? (Probably alternative — keep TOTP as fallback)
|
||||
|
||||
### Phase 3 — Multi-User Support
|
||||
|
||||
**Goal:** Support multiple TOTP users for household/family access.
|
||||
|
||||
*Note: This is a significant feature that changes the single-secret
|
||||
model. It should only be pursued if the single-secret + backup codes
|
||||
approach proves insufficient for the use case.*
|
||||
|
||||
- [ ] Multiple TOTP secrets, each with a label (e.g., "mom", "dad",
|
||||
"friend")
|
||||
- [ ] Per-user backup codes
|
||||
- [ ] Per-user session tracking (the `username` field in Payload already
|
||||
supports this — sessions are already tagged with an ID)
|
||||
- [ ] Console command to add/remove/list users
|
||||
- [ ] Consider: should the login page ask for a username, or should all
|
||||
TOTP codes be tried against all secrets? (Username is better —
|
||||
it's already in the payload.)
|
||||
- [ ] Tests for multi-user scenarios
|
||||
|
||||
### Phase 4 — Polish & Hardening
|
||||
|
||||
**Goal:** Production hardening and quality-of-life improvements.
|
||||
|
||||
- [ ] **Docker image improvements:**
|
||||
- Multi-arch builds (amd64 + arm64 for Raspberry Pi)
|
||||
- Smaller image size (alpine-based if feasible)
|
||||
- Better health check (actual endpoint, not just `curl localhost`)
|
||||
- [ ] **GitHub/Gitea repository polish:**
|
||||
- Comprehensive README with setup guide, architecture overview, and
|
||||
configuration reference
|
||||
- Contributing guidelines
|
||||
- Changelog (currently inline in README — formalise it)
|
||||
- GitHub Actions CI (run tests on push/PR, build 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
|
||||
- Review nonce entropy and cache key collision space
|
||||
- Consider session fixation protections
|
||||
- [ ] **Frontend improvements:**
|
||||
- Mobile-responsive login page audit
|
||||
- Accessibility audit (ARIA labels, keyboard navigation)
|
||||
- Dark mode (if not already — the teal background suggests it might
|
||||
already be dark-themed)
|
||||
- [ ] **Logging improvements:**
|
||||
- Structured logging (JSON format option) for easier parsing
|
||||
- Log rotation configuration
|
||||
- Debug mode documentation
|
||||
|
||||
---
|
||||
|
||||
## Feature Thoughts
|
||||
|
||||
Based on the review, here are features that might be missing or worth
|
||||
considering, keeping in mind that preauth is a **gate**, not a full
|
||||
identity provider:
|
||||
|
||||
### High Value
|
||||
|
||||
1. **Public but rate-limited mode** (Phase 1) — Directly solves the
|
||||
Gitea bot traffic problem. This is the most impactful missing
|
||||
feature.
|
||||
|
||||
2. **Passkey authentication** (Phase 2c) — Phishing-resistant,
|
||||
passwordless auth that's far more user-friendly than TOTP for
|
||||
non-technical family members. The modern standard for this kind
|
||||
of gate.
|
||||
|
||||
3. **Backup code notifications** (Phase 2b) — When a backup code is
|
||||
used, you should know about it immediately. This is a security-critical
|
||||
event — it means someone lost their device or is locked out of their
|
||||
TOTP app. Discord/ntfy/email notification should fire automatically.
|
||||
|
||||
4. **Backup code management commands** (Phase 2b) — The `generate`
|
||||
command exists, but `list` and `expire` commands are missing despite
|
||||
the underlying methods (`expire()`) already being implemented.
|
||||
|
||||
5. **Session visibility and revocation** (Phase 2) — Currently there's
|
||||
no way to see who has access or revoke a session without clearing
|
||||
the entire cache. For a security tool, this is important.
|
||||
|
||||
6. **Audit log** (Phase 2) — For a security gate, not having an audit
|
||||
trail of logins (successful and failed) is a gap. The data is logged
|
||||
at debug level, but not persisted in a queryable format.
|
||||
|
||||
### Medium Value
|
||||
|
||||
4. **Health check endpoint** — The Dockerfile has a `HEALTHCHECK` that
|
||||
just `curl`s localhost, but a dedicated `/health` endpoint that
|
||||
verifies cache connectivity would be more meaningful.
|
||||
|
||||
5. **Graceful degradation** — If the file-based cache is corrupted or
|
||||
unavailable, does preauth fail open or closed? Should be documented
|
||||
and tested. (Currently the `PersistCache` handles this in `boot()`,
|
||||
but edge cases around partial corruption could be explored.)
|
||||
|
||||
6. **Rate limit headers** — Adding `X-RateLimit-Remaining` and
|
||||
`Retry-After` headers to rate-limited responses would help legitimate
|
||||
clients back off gracefully.
|
||||
|
||||
### Lower Value (Nice to Have)
|
||||
|
||||
7. **WebSocket support** — If protected services use WebSocket
|
||||
connections, does `forward_auth` handle the upgrade handshake? This
|
||||
is likely a Caddy configuration concern, but worth documenting.
|
||||
|
||||
8. **Theming presets** — Beyond the current env-var colour config,
|
||||
preset themes or custom CSS upload could be nice for personalisation.
|
||||
|
||||
9. **TOTP secret rotation** — Console command to generate a new TOTP
|
||||
secret and invalidate all existing sessions. Useful if a device is
|
||||
lost or compromised.
|
||||
|
||||
10. **Per-service authentication policies** — Different services could
|
||||
require different authentication strength (e.g., Bitwarden requires
|
||||
TOTP + recent login, Microbin accepts any valid session). This would
|
||||
need Caddy configuration support to pass the policy to preauth.
|
||||
|
||||
---
|
||||
|
||||
## Branch Status
|
||||
|
||||
| Branch | Status | Notes |
|
||||
|--------|--------|-------|
|
||||
| `main` (0.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. |
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Other Projects
|
||||
|
||||
| Project | Integration |
|
||||
|---------|-------------|
|
||||
| MCP server | Preauth could be registered as an MCP command for session management ("revoke all sessions", "who's logged in?") |
|
||||
| Email integration | Audit log entries could be included in morning summary ("2 failed login attempts from 203.0.113.50 overnight") |
|
||||
| Discord/ntfy | Alert on backup code usage, suspicious activity (rate limit triggered, multiple failed attempts from new IP), low backup code count |
|
||||
|
||||
---
|
||||
|
||||
*Prepared by Lyra, your office-side assistant. ✨*
|
||||
Executable
BIN
Binary file not shown.
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||
}
|
||||
|
||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
||||
return new Application($kernel);
|
||||
};
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Dev utility — builds and runs the preauth container locally.
|
||||
# Not for production use.
|
||||
|
||||
docker container rm preauth
|
||||
docker build . -t digitaladapt/preauth:dev
|
||||
docker run --name preauth \
|
||||
-e APP_ENV=dev \
|
||||
-e APP_DEBUG=true \
|
||||
-e APP_SECRET=f88a1074691c40415be4439345b79f69 \
|
||||
-e APP_SHARE_DIR=var/share \
|
||||
-e DEFAULT_URI=http://localhost \
|
||||
-v ./var/share:/app/var/share \
|
||||
-p 8000:80 \
|
||||
digitaladapt/preauth:dev
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
@@ -1,19 +0,0 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# TODO rename ".env.example" to just ".env"
|
||||
# edit USER_ID and GROUP_ID if needed
|
||||
# set PREAUTH_KEY and PREAUTH_TOKEN after initial start to persist those settings
|
||||
- .env
|
||||
expose:
|
||||
- 9000
|
||||
image: digitaladapt/preauth
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
user: ${USER_ID}:${GROUP_ID}
|
||||
volumes:
|
||||
- preauth:/tmp/data
|
||||
|
||||
volumes:
|
||||
preauth:
|
||||
|
||||
+76
-5
@@ -1,12 +1,83 @@
|
||||
{
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"bacon/bacon-qr-code": "^3.1.1",
|
||||
"runtime/frankenphp-symfony": "^1.0.0",
|
||||
"spomky-labs/otphp": "^11.4.2",
|
||||
"symfony/cache": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/flex": "^2.11",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/yaml": "7.4.*"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true,
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"bump-after-update": true,
|
||||
"sort-packages": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Preauth\\": "src/"
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"spomky-labs/otphp": "^11.3",
|
||||
"symfony/http-foundation": "^7.3",
|
||||
"symfony/uid": "^7.3"
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php72": "*",
|
||||
"symfony/polyfill-php73": "*",
|
||||
"symfony/polyfill-php74": "*",
|
||||
"symfony/polyfill-php80": "*",
|
||||
"symfony/polyfill-php81": "*",
|
||||
"symfony/polyfill-php82": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"runtime": {
|
||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
},
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "*",
|
||||
"phpunit/phpunit": "^13.2",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
framework:
|
||||
cache:
|
||||
app: cache.adapter.filesystem
|
||||
pools:
|
||||
nonceCache:
|
||||
adapters: cache.adapter.apcu
|
||||
rateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
sessionCache:
|
||||
adapters: cache.adapter.apcu
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.filesystem
|
||||
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
prefix_seed: digitaladapt/preauth
|
||||
@@ -0,0 +1,9 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
|
||||
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
|
||||
@@ -0,0 +1,15 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
burst:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:BURST_COUNT)%'
|
||||
interval: '%env(int:BURST_TIME)% seconds'
|
||||
cache_pool: 'rateLimitCache'
|
||||
upper:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:UPPER_COUNT)%'
|
||||
interval: '%env(int:UPPER_TIME)% seconds'
|
||||
cache_pool: 'rateLimitCache'
|
||||
login_limiter:
|
||||
policy: compound
|
||||
limiters: [burst, upper]
|
||||
@@ -0,0 +1,8 @@
|
||||
framework:
|
||||
router:
|
||||
default_uri: 'http://localhost'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
@@ -0,0 +1,12 @@
|
||||
framework:
|
||||
cache:
|
||||
app: cache.adapter.array
|
||||
pools:
|
||||
nonceCache:
|
||||
adapters: cache.adapter.array
|
||||
rateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
sessionCache:
|
||||
adapters: cache.adapter.array
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.array
|
||||
@@ -0,0 +1,4 @@
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
@@ -0,0 +1,18 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
strict_variables: true
|
||||
globals:
|
||||
env:
|
||||
title: '%env(TITLE)%'
|
||||
bg_color: '%env(BG_COLOR)%'
|
||||
fg_color: '%env(FG_COLOR)%'
|
||||
error_color: '%env(ERROR_COLOR)%'
|
||||
id_name: '%env(ID_NAME)%'
|
||||
token_name: '%env(TOKEN_NAME)%'
|
||||
submit_name: '%env(SUBMIT_NAME)%'
|
||||
error_message: '%env(ERROR_MESSAGE)%'
|
||||
teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
teapot_message: '%env(TEAPOT_MESSAGE)%'
|
||||
too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
too_many_message: '%env(TOO_MANY_MESSAGE)%'
|
||||
debug: '%env(SHELL_VERBOSITY)%'
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__) .
|
||||
'/var/cache/prod/App_KernelProdContainer.preload.php')
|
||||
) {
|
||||
require dirname(__DIR__) .
|
||||
'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
# See also https://symfony.com/doc/current/service_container/import.html
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html
|
||||
# #use-parameters-for-application-configuration
|
||||
parameters:
|
||||
# --- main options ---
|
||||
# URI containing secret and config for TOTP, which determines the token to login
|
||||
# app will generate one, if not provided, but you should copy it to your .env file
|
||||
# format: "otpauth://totp/<label>?secret=<secret-key>"
|
||||
env(TOTP_URI): '' # blank to have the app generate one at random
|
||||
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
|
||||
env(COOKIE_TTL): '2592000' # default 30 days
|
||||
# Enable optional redirection to a dedicated authentication subdomain
|
||||
env(SUBDOMAIN_REDIRECT): '0' # boolean, 1 to enable
|
||||
# The subdomain (e.g., auth.example.com) to which unauthenticated users are redirected
|
||||
env(AUTH_SUBDOMAIN): ''
|
||||
|
||||
# --- extra options ---
|
||||
# how long do we allow all traffic from an ip address after successful login
|
||||
# could be useful if you have a system which does not handle cookies
|
||||
env(IP_TTL): '0' # default disabled, time in seconds
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
env(TEAPOT): '1' # boolean
|
||||
|
||||
# --- 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
|
||||
env(BURST_COUNT): 2 # 2 per 30 seconds
|
||||
env(BURST_TIME): 30 # seconds
|
||||
env(UPPER_COUNT): 10 # 10 per hour
|
||||
env(UPPER_TIME): 3600 # seconds (1 hour)
|
||||
|
||||
# --- styling options ---
|
||||
env(TITLE): 'Pre-Authentication System'
|
||||
env(BG_COLOR): '#029386' # teal
|
||||
env(FG_COLOR): '#ffffff' # white
|
||||
env(ERROR_COLOR): '#ffb16d' # apricot (light orange)
|
||||
env(ID_NAME): 'Session ID'
|
||||
env(TOKEN_NAME): 'Authentication Token'
|
||||
env(SUBMIT_NAME): 'Submit'
|
||||
env(ERROR_MESSAGE): 'Unsuccessful login attempt'
|
||||
# title and message to use on block page, if teapot is true
|
||||
env(TEAPOT_TITLE): "I'm a teapot"
|
||||
env(TEAPOT_MESSAGE): 'I refuse to brew coffee'
|
||||
# title and message to use on block page, if teapot is false
|
||||
env(TOO_MANY_TITLE): 'Too many requests'
|
||||
env(TOO_MANY_MESSAGE): 'Try again later'
|
||||
|
||||
# --- debug options ---
|
||||
env(SHELL_VERBOSITY): '0' # set to 3 to log debug
|
||||
|
||||
# --- application variables ---
|
||||
app.totp_uri: '%env(TOTP_URI)%'
|
||||
app.cookie_ttl: '%env(COOKIE_TTL)%'
|
||||
app.subdomain_redirect: '%env(SUBDOMAIN_REDIRECT)%'
|
||||
app.auth_subdomain: '%env(AUTH_SUBDOMAIN)%'
|
||||
|
||||
app.ip_ttl: '%env(IP_TTL)%'
|
||||
app.teapot: '%env(TEAPOT)%'
|
||||
|
||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services.
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
@@ -0,0 +1,28 @@
|
||||
# example of securing full service
|
||||
# TODO replace domain and service name and port
|
||||
service.example.com {
|
||||
forward_auth preauth {
|
||||
uri {uri}
|
||||
copy_headers Remote-User
|
||||
}
|
||||
reverse_proxy service-container:80
|
||||
}
|
||||
|
||||
# you can choose to only restrict select paths
|
||||
# or any other Caddy match criteria, if desired
|
||||
# IE: https://protected.example.com/secure/
|
||||
protected.example.com {
|
||||
# note any request that does not start with "/secure/" is NOT protected
|
||||
forward_auth /secure/* preauth {
|
||||
uri {uri}
|
||||
copy_headers Remote-User
|
||||
}
|
||||
reverse_proxy protected-service:9000
|
||||
}
|
||||
|
||||
# optionally, if you want to use a subdomain for centeral preauth
|
||||
# set SUBDOMAIN_REDIRECT to true
|
||||
# and AUTH_SUBDOMAIN to match the subdomain you use here
|
||||
auth.example.com {
|
||||
reverse_proxy preauth
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
preauth:
|
||||
env_file:
|
||||
# TODO 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
|
||||
expose:
|
||||
- 80
|
||||
image: digitaladapt/preauth:latest
|
||||
restart: unless-stopped
|
||||
# if you wish to set the user, you must make sure that the user
|
||||
# can write to /config and /data within the container
|
||||
#user: <uid>:<gid>
|
||||
volumes:
|
||||
- preauth-config:/config
|
||||
- preauth-data:/data
|
||||
|
||||
volumes:
|
||||
preauth-config:
|
||||
preauth-data:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# --- main options ---
|
||||
|
||||
# URI containing secret and config for TOTP, which determines the token to login
|
||||
# app will generate one, if not provided, but you should copy it to your .env file
|
||||
# format: "otpauth://totp/<label>?secret=<secret-key>"
|
||||
#TOTP_URI='' # blank to have the app generate one at random
|
||||
|
||||
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
|
||||
#COOKIE_TTL=2592000 # default 30 days
|
||||
|
||||
# we can use a central auth, so that users only need to login once to have access to
|
||||
# multiple services. Requires using sub-domains under the same domain.
|
||||
# IE: if enabled have "service-one.example.com" redirect "auth.example.com", and after
|
||||
# successful auth, user can visit "service-two.example.com" without having to login again.
|
||||
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
|
||||
#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page
|
||||
|
||||
# --- extra options ---
|
||||
|
||||
# how long do we allow *ALL* traffic from an ip address after successful login
|
||||
# could be useful if you have a system which does not handle cookies
|
||||
#IP_TTL=0 # default disabled, time in seconds
|
||||
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
#TEAPOT=true # default enabled, boolean
|
||||
|
||||
# --- 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
|
||||
#BURST_COUNT=2 # 2 per 30 seconds
|
||||
#BURST_TIME=30 # seconds
|
||||
#UPPER_COUNT=10 # 10 per hour
|
||||
#UPPER_TIME=3600 # seconds (1 hour)
|
||||
|
||||
# --- styling options ---
|
||||
|
||||
#TITLE='Pre-Authentication System'
|
||||
#BG_COLOR='#029386' # teal
|
||||
#FG_COLOR='#ffffff' # white
|
||||
#ERROR_COLOR='#ffb16d' # apricot (light orange)
|
||||
#ID_NAME='Session ID'
|
||||
#TOKEN_NAME='Authentication Token'
|
||||
#SUBMIT_NAME='Submit'
|
||||
#ERROR_MESSAGE='Unsuccessful login attempt'
|
||||
# title and message to use on block page, if teapot is true
|
||||
#TEAPOT_TITLE="I'm a teapot"
|
||||
#TEAPOT_MESSAGE='I refuse to brew coffee'
|
||||
# title and message to use on block page, if teapot is false
|
||||
#TOO_MANY_TITLE='Too many requests'
|
||||
#TOO_MANY_MESSAGE='Try again later'
|
||||
|
||||
# --- debug options ---
|
||||
#SHELL_VERBOSITY=0 # set to "3" to log debug
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
/* do garbage collection on rate limit monitoring sessions */
|
||||
session_start();
|
||||
$success = session_gc();
|
||||
session_destroy();
|
||||
|
||||
if ($success !== false) {
|
||||
/* if garbage collection was successful, report that we are healthy */
|
||||
echo implode('', ['o', 'n', 'l', 'i', 'n', 'e', "\n"]);
|
||||
} else {
|
||||
echo "error\n";
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$key = getenv('PREAUTH_KEY');
|
||||
|
||||
// generate a new key, if not specified
|
||||
if ( ! $key) {
|
||||
$key = base64_encode(random_bytes(64));
|
||||
error_log("ERROR: PREAUTH_KEY is not set, generating a random session encryption key:\n'$key'\nupdate your config or it will be regenerated when you restart.");
|
||||
}
|
||||
|
||||
echo $key;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use OTPHP\TOTP;
|
||||
|
||||
$token = getenv('PREAUTH_TOKEN');
|
||||
|
||||
// generate a new token, if not specified
|
||||
if ( ! $token) {
|
||||
$token = TOTP::generate()->getSecret();
|
||||
error_log("ERROR: PREAUTH_TOKEN is not set, generating random TOTP token:\n'$token'\nupdate your config or it will be regenerated when you restart.");
|
||||
}
|
||||
|
||||
echo $token;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ensure we have required folders
|
||||
`mkdir -p /tmp/data/sessions`
|
||||
`mkdir -p /tmp/data/monitor`
|
||||
|
||||
# ensure we have required settings
|
||||
if [ -z "$PREAUTH_KEY" ]; then
|
||||
export PREAUTH_KEY=$(cd /preauth && php init-key.php)
|
||||
fi
|
||||
|
||||
if [ -z "$PREAUTH_TOKEN" ]; then
|
||||
export PREAUTH_TOKEN=$(cd /preauth && php init-token.php)
|
||||
fi
|
||||
|
||||
# start the process, now that setup is done
|
||||
exec php-fpm
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
<server name="KERNEL_CLASS" value="App\Tests\TestKernel" />
|
||||
<!-- fixed TOTP secret so functional tests can compute valid codes -->
|
||||
<server name="TOTP_URI" value="otpauth://totp/Test-TOTP?secret=JBSWY3DPEHPK3PXP" />
|
||||
<server name="APP_SECRET" value="test_secret_key_change_me" />
|
||||
<!-- high rate limits so functional tests don't get blocked -->
|
||||
<server name="BURST_COUNT" value="10000" />
|
||||
<server name="UPPER_COUNT" value="10000" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source ignoreSuppressionOfDeprecations="true"
|
||||
ignoreIndirectDeprecations="true"
|
||||
restrictNotices="true"
|
||||
restrictWarnings="true"
|
||||
>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
<extensions>
|
||||
</extensions>
|
||||
</phpunit>
|
||||
@@ -1,21 +0,0 @@
|
||||
; preauth uses php sessions to store usage statistics by IP address and is used for rate limiting
|
||||
; login sessions are stored as file in /tmp/data/sessions, and are unrelated to this configuration
|
||||
|
||||
; sessions are IP based, cookie not needed nor wanted
|
||||
session.use_cookies = off
|
||||
; deprecated in php 8.4
|
||||
;session.use_only_cookies = off
|
||||
session.cache_limiter = ''
|
||||
session.name = preauth
|
||||
session.save_path = /tmp/data/monitor
|
||||
|
||||
; seconds until a session may be pruned
|
||||
; IE: maximum time range to review for rate limiting and
|
||||
; maximum time an IP address can be blocked
|
||||
; 86400 seconds aka 24 hours hardcoded upper limit
|
||||
session.gc_maxlifetime = 86400
|
||||
|
||||
; disable random random garbage collection
|
||||
; our healthcheck runs session_gc function
|
||||
session.gc_probability = 0
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Preauth\Auth;
|
||||
|
||||
/* kickoff the script */
|
||||
$auth = new Auth($_SERVER['HTTP_HOST'] ?? 'example.com');
|
||||
$auth->run();
|
||||
|
||||
/* no additional output most of the time, sometimes we'll continue to display the login screen */
|
||||
if ($auth->showTemplate()) {
|
||||
include __DIR__ . '/template.php';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
@@ -1,27 +1,88 @@
|
||||
# Preauth
|
||||
Pre-authorization, because sometimes you need both a belt and suspenders.
|
||||
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.
|
||||
|
||||
The goal of this is to make it as simple as possible to put a web service behind an extra layer of authentication.
|
||||
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.
|
||||
|
||||
Maybe you need the extra protection because it's a very sensitive system, or because it's a legacy system with known security issues.
|
||||
So, I built a simple authentication gateway, which eventually turned into this project.
|
||||
|
||||
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
|
||||
|
||||
## Development
|
||||
|
||||
### Code Style
|
||||
|
||||
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and includes `php-cs-fixer` as a dev dependency.
|
||||
|
||||
```bash
|
||||
# Check for style violations
|
||||
vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
|
||||
# Auto-fix
|
||||
vendor/bin/php-cs-fixer fix
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
* Docker
|
||||
* Caddy (as a reverse proxy)
|
||||
* a domain
|
||||
* a web service you want to secure
|
||||
|
||||
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
|
||||
|
||||
There is an example Caddyfile and example .env file to get you started. Within the Caddyfile is a snippet, which makes it easy to wrap your web service with preauth.
|
||||
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.
|
||||
|
||||
Preauth will need a subdomain on the same domain as the service it's securing, the default is "preauth", but you can use whatever you want.
|
||||
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.
|
||||
|
||||
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 redirect them to a login screen.
|
||||
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.
|
||||
|
||||
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 redirected to the protected service.
|
||||
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.
|
||||
|
||||
First time you spin up the docker container it will generate an encryption key for session storage, and the TOTP secret (which you'll load into your authenticator app).
|
||||
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
|
||||
|
||||
Be sure to save those and add them to the containers environment, or it will generate new values every time it restarts.
|
||||
## 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.
|
||||
|
||||
-356
@@ -1,356 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Preauth;
|
||||
|
||||
use OTPHP\TOTP;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class Auth {
|
||||
/* name of the return-to field */
|
||||
public const RETURN_FIELD = 'preauth_rt';
|
||||
/* name of the id field */
|
||||
public const ID_FIELD = 'preauth_id';
|
||||
/* name of the token field */
|
||||
public const TOKEN_FIELD = 'preauth_token';
|
||||
/* the encryption cipher we are using */
|
||||
private const CIPHER = 'camellia-256-ctr';
|
||||
/* only allow A-z 0-9 _ - */
|
||||
private const URL64 = '/[^A-Za-z0-9_-]+/';
|
||||
/* cookie name */
|
||||
private const NAME = '_auth_uuid';
|
||||
/* directory to store sessions in */
|
||||
private const BASE = '/tmp/data/sessions/';
|
||||
/* top-level-domains which are known to have multiple parts */
|
||||
private const TLD = [
|
||||
'ai' => ['com','net','off','org'],
|
||||
'am' => ['radio'],
|
||||
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za'],
|
||||
'de' => ['com'],
|
||||
'fm' => ['radio'],
|
||||
'gg' => ['co','net','org'],
|
||||
'in' => ['co','firm','gen','ind','net','org'],
|
||||
'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'],
|
||||
];
|
||||
|
||||
/** @var string $preauth subdomain (without domain) the login page will use */
|
||||
private string $preauth;
|
||||
/** @var string $domain domain (without subdomains) we are controlling auth for */
|
||||
private string $domain;
|
||||
/** @var int $expire time-to-live of auth cookie */
|
||||
private int $expire;
|
||||
/** @var array $get like $_GET, but based on data given by reverse proxy */
|
||||
private array $get = [];
|
||||
/** @var string $key secret for encryption to store sessions */
|
||||
private string $key;
|
||||
/** @var string $token secret for 2FA token */
|
||||
private string $token;
|
||||
/** @var bool $stop set to false to print login page */
|
||||
private bool $stop = true;
|
||||
/** @var string $id user provided name for their session */
|
||||
private string $id;
|
||||
|
||||
/**
|
||||
* @param string $host domain which auth is relative to
|
||||
*/
|
||||
public function __construct(string $host) {
|
||||
$this->preauth = getenv('PREAUTH_SUBDOMAIN') ?: 'preauth';
|
||||
$this->domain = $this->baseDomain($host);
|
||||
$this->key = base64_decode(getenv('PREAUTH_KEY') ?: '');
|
||||
$this->token = getenv('PREAUTH_TOKEN') ?: '';
|
||||
$this->expire = time() + 60 * ((int)getenv('PREAUTH_TTL') ?: 43200);
|
||||
parse_str((parse_url((
|
||||
$_SERVER['HTTP_X_FORWARDED_URI'] ?? ''
|
||||
), PHP_URL_QUERY) ?: ''), $this->get);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean returns true if we should display login page, false if we are done
|
||||
*/
|
||||
public function showTemplate(): bool {
|
||||
return ( ! $this->stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the return-to-url, if one was specified, empty string otherwise
|
||||
*/
|
||||
public function getReturnTo(): string {
|
||||
$rt = $this->get[self::RETURN_FIELD] ?? '';
|
||||
if ( ! is_string($rt)) {
|
||||
$rt = '';
|
||||
}
|
||||
return $rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns our current domain with all subdomains removed
|
||||
*/
|
||||
public function getBaseDomain(): string {
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point of code, review request and determine course of action
|
||||
*/
|
||||
public function run(): void {
|
||||
if ( ! $this->key || ! $this->token) {
|
||||
$this->die();
|
||||
}
|
||||
|
||||
/* already logged in with valid session, return 200, so caddy permits request */
|
||||
$uuid = $this->getExistingUUID();
|
||||
if ($uuid) {
|
||||
echo "ok $this->id";
|
||||
return;
|
||||
}
|
||||
|
||||
/* if too many requests (from the remote-ip), then trigger rate limiting */
|
||||
if ($this->rateLimit()) {
|
||||
include __DIR__ . '/../400.php';
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/* if request is valid login attempt, (set cookie and) return to where they came from */
|
||||
if ($this->login()) {
|
||||
if ($this->getReturnTo()) {
|
||||
header("Location: {$this->getReturnTo()}");
|
||||
} else if (getenv('PREAUTH_SEND_TO')) {
|
||||
header("Location: " . getenv('PREAUTH_SEND_TO'));
|
||||
}
|
||||
echo "ok $this->id";
|
||||
return;
|
||||
}
|
||||
|
||||
/* not already logged in, but on auth page, so present login screen */
|
||||
/* either not trying to login or had a failed login attempt */
|
||||
if ($_SERVER['HTTP_HOST'] === "$this->preauth.$this->domain") {
|
||||
header('http/1.1 401 Unauthorized', true, 401);
|
||||
$this->stop = false;
|
||||
} else {
|
||||
/* not already logged in, not trying to login, and not on auth page */
|
||||
/* so send to login screen */
|
||||
$query = self::RETURN_FIELD . '=' . rawurlencode(
|
||||
($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https') . '://' .
|
||||
($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $this->domain) .
|
||||
($_SERVER['HTTP_X_FORWARDED_URI'] ?? '/')
|
||||
);
|
||||
header("Location: https://$this->preauth.$this->domain/?$query");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This lets us determine the base domain:
|
||||
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
||||
* @param string $host domain with zero or more subdomains
|
||||
* @return string returns same domain with all subdomains removed
|
||||
*/
|
||||
private function baseDomain(string $host): string {
|
||||
$parts = array_reverse(explode('.', $host));
|
||||
$keep = min(2, count($parts));
|
||||
/* check if host should retain 3 parts, due to TLD */
|
||||
if (count($parts) > 2 && isset(self::TLD[$parts[0]]) &&
|
||||
in_array($parts[1], self::TLD[$parts[0]], true)
|
||||
) {
|
||||
$keep = 3;
|
||||
}
|
||||
$parts = array_reverse(array_slice($parts, 0, $keep));
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop, critical server config issue
|
||||
*/
|
||||
private function die(): string {
|
||||
error_log('PREAUTH_KEY and/or PREAUTH_TOKEN are not set, unable to continue.');
|
||||
header('http/1.1 500 Internal Server Error', true, 500);
|
||||
include __DIR__ . '/../500.php';
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null returns the existing UUID if there is valid one, null otherwise
|
||||
*/
|
||||
private function getExistingUUID(): ?string {
|
||||
/* filter user input */
|
||||
$encUUID = preg_replace(self::URL64, '', ($_COOKIE[self::NAME] ?? ''));
|
||||
|
||||
/* session exists as a file where the filename is the encoded-uuid */
|
||||
/* in the format '<iv-b64>$<session-name>$<expiration>$<date>$<remote-host>' */
|
||||
if ($encUUID && is_file(self::BASE . $encUUID)) {
|
||||
[$iv, $id, $expire] = explode('$', (file_get_contents(
|
||||
self::BASE . $encUUID
|
||||
) ?: '') . '$$');
|
||||
if ($iv) {
|
||||
/* raw-uuid means not encoded, but still encrypted */
|
||||
$rawUUID = base64_decode(strtr($encUUID, '+/', '-_'));
|
||||
$rawIV = base64_decode($iv);
|
||||
$uuid = openssl_decrypt($rawUUID, self::CIPHER, $this->key, 0, $rawIV);
|
||||
if (Uuid::isValid($uuid) && (int)$expire >= time()) {
|
||||
$this->id = substr(preg_replace(self::URL64, '', $id), 0, 100);
|
||||
return $uuid;
|
||||
}
|
||||
/* we have something encrypted, but it is expired or invalid, so delete it */
|
||||
unlink(self::BASE . $encUUID);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the remote-host has made too many login requests recently, and block if needed
|
||||
* @return bool returns true if we should rate-limit this request, false otherwise
|
||||
*/
|
||||
private function rateLimit(): bool {
|
||||
/* our identifier for this remote-host, replace all special characters with dashes */
|
||||
/* session_id() only allows ",", "-", and alphanumeric characters */
|
||||
$limiterId = preg_replace('/[^a-zA-Z0-9]/', '-', $this->getRemoteHost());
|
||||
//error_log("limiterId: {$limiterId}");
|
||||
session_id($limiterId);
|
||||
session_start();
|
||||
|
||||
/* new remote-ip, start logging */
|
||||
if ( ! isset($_SESSION['count'], $_SESSION['time'])) {
|
||||
$this->resetRateLimit(false);
|
||||
//error_log('limiter new, allow');
|
||||
return false;
|
||||
}
|
||||
|
||||
$sessionCount = (int)$_SESSION['count'];
|
||||
$sessionTime = (int)$_SESSION['time'];
|
||||
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
|
||||
$rateTimeout = (int)getenv('PREAUTH_RATE_TIMEOUT') ?: 360;
|
||||
$rateBlocked = (int)getenv('PREAUTH_RATE_BLOCKED') ?: 1440;
|
||||
$rateMaximum = max($rateTimeout, $rateBlocked);
|
||||
|
||||
if ($sessionCount >= $rateLimit && time() - $rateBlocked <= $sessionTime) {
|
||||
/* if over limit, and block current, block them */
|
||||
//error_log("limiter over limit, block ($sessionCount)");
|
||||
return true;
|
||||
} else if ($sessionCount < $rateLimit && time() - $rateTimeout <= $sessionTime) {
|
||||
/* if under limit, and timeout current, allow them */
|
||||
//error_log("limiter under limit, allow ($sessionCount)");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* either over limit and block has expired or */
|
||||
/* under limit and timeout has expired, so reset them */
|
||||
//error_log("limiter expired, reset");
|
||||
$this->resetRateLimit(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns a newly generated random uuid
|
||||
*/
|
||||
private function newUUID(): string {
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upon valid token and non-empty id, creates a uuid, stores session file and sets the cookie
|
||||
* @return bool returns true if token and id are provided, and token is valid, false otherwise
|
||||
*/
|
||||
private function login(): bool {
|
||||
/* fields must both be filled out */
|
||||
if (isset($this->get[self::TOKEN_FIELD], $this->get[self::ID_FIELD]) &&
|
||||
$this->get[self::TOKEN_FIELD] && $this->get[self::ID_FIELD]
|
||||
) {
|
||||
/* filter user input */
|
||||
$id = substr(preg_replace(self::URL64, '', $this->get[self::ID_FIELD]), 0, 100);
|
||||
$otp = TOTP::createFromSecret($this->token);
|
||||
$date = date('Y-m-d H:i:s');
|
||||
$remoteHost = $this->getRemoteHost();
|
||||
|
||||
/* if given token is valid, login, store session file and set the cookie */
|
||||
if ($otp->now() === $this->get[self::TOKEN_FIELD]) {
|
||||
$this->id = $id;
|
||||
$uuid = $this->newUUID();
|
||||
$rawIV = random_bytes(openssl_cipher_iv_length(self::CIPHER));
|
||||
$rawUUID = openssl_encrypt($uuid, self::CIPHER, $this->key, 0, $rawIV);
|
||||
$encUUID = strtr(base64_encode($rawUUID), '-_', '+/');
|
||||
file_put_contents(
|
||||
self::BASE . $encUUID,
|
||||
base64_encode($rawIV) .
|
||||
"\$$id\$$this->expire\$$date\$$remoteHost\$\n"
|
||||
);
|
||||
setcookie(
|
||||
self::NAME,
|
||||
$encUUID,
|
||||
$this->expire,
|
||||
'/', /* all paths */
|
||||
$this->domain, /* all subdomains */
|
||||
true, /* https only */
|
||||
true /* no js access */
|
||||
);
|
||||
error_log("[$date] successful login by id: $id");
|
||||
/* successful login, reset the rate-limit and close */
|
||||
$this->resetRateLimit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* login attempted and failed, update monitoring for rate limiting */
|
||||
$sessionCount = (int)$_SESSION['count'];
|
||||
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
|
||||
$this->logFailedAttempt();
|
||||
|
||||
if (($sessionCount + 1) >= $rateLimit) {
|
||||
/* +1 to count this failure */
|
||||
error_log("[$date] rate-limiting trigger for: $remoteHost");
|
||||
/* just reached the rate limit, block them */
|
||||
include __DIR__ . '/../400.php';
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine real remote-host, if local address, find next level up
|
||||
* @return string returns the real remote host (IP address)
|
||||
*/
|
||||
private function getRemoteHost(): string {
|
||||
$remoteHost = $_SERVER['REMOTE_HOST'];
|
||||
if (IpUtils::isPrivateIp($remoteHost)) {
|
||||
$remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
|
||||
$remoteIndex = array_search($remoteHost, $remoteList);
|
||||
if ($remoteIndex > 0) {
|
||||
$remoteHost = $remoteList[$remoteIndex - 1];
|
||||
}
|
||||
}
|
||||
return $remoteHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* [Re]Initialize the rate-limiting and close monitoring session (unless you pass false)
|
||||
* @param bool $close Defaults to true, set to false to keep monitoring session open
|
||||
*/
|
||||
private function resetRateLimit(bool $close = true): void {
|
||||
$_SESSION['time'] = time();
|
||||
$_SESSION['count'] = 0;
|
||||
if ($close) {
|
||||
session_write_close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that login failed so we can determine if we should rate-limit future requests
|
||||
*/
|
||||
private function logFailedAttempt(): void {
|
||||
$_SESSION['time'] = time();
|
||||
$_SESSION['count']++;
|
||||
session_write_close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
|
||||
|
||||
#[AsAlias(ClockInterface::class)]
|
||||
final readonly class Clock implements ClockInterface
|
||||
{
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\PersistCache;
|
||||
use App\Service\BackupCodeInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/** simple console command to generate backup codes
|
||||
* usage: php bin/console app:generate-backup-codes [count] */
|
||||
final class GenerateBackupCodesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BackupCodeInterface $manager,
|
||||
private readonly PersistCache $persistCache,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('app:generate-backup-codes');
|
||||
$this->setDescription('Generate single‑use backup codes')
|
||||
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
|
||||
$this->persistCache->boot();
|
||||
$count = (int) $input->getArgument('count');
|
||||
$codes = $this->manager->generate($count);
|
||||
foreach ($codes as $code) {
|
||||
$output->writeln($code);
|
||||
}
|
||||
$this->persistCache->persist();
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class ConfigBag
|
||||
{
|
||||
private ClockInterface $clock;
|
||||
private int $cookieTtl;
|
||||
private string $totpUri;
|
||||
private ?int $ipTtl;
|
||||
private bool $teapot;
|
||||
private string $errorMessage;
|
||||
private string $teapotTitle;
|
||||
private string $tooManyTitle;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
Utilities $utilities,
|
||||
ClockInterface $clock,
|
||||
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
|
||||
#[Autowire('%app.totp_uri%')] string $totpUri,
|
||||
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
|
||||
#[Autowire('%app.teapot%')] bool $teapot,
|
||||
#[Autowire('%app.error_message%')] string $errorMessage,
|
||||
#[Autowire('%app.teapot_title%')] string $teapotTitle,
|
||||
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
|
||||
) {
|
||||
$this->clock = $clock;
|
||||
$this->cookieTtl = $cookieTtl;
|
||||
$this->totpUri = $totpUri ?: $utilities->loadTotp();
|
||||
$this->ipTtl = $ipTtl ?: null;
|
||||
$this->teapot = $teapot;
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->teapotTitle = $teapotTitle;
|
||||
$this->tooManyTitle = $tooManyTitle;
|
||||
}
|
||||
|
||||
public function clock(): ClockInterface
|
||||
{
|
||||
return $this->clock;
|
||||
}
|
||||
|
||||
public function cookieTtl(): int
|
||||
{
|
||||
return $this->cookieTtl;
|
||||
}
|
||||
|
||||
public function totpUri(): string
|
||||
{
|
||||
return $this->totpUri;
|
||||
}
|
||||
|
||||
public function ipTtl(): ?int
|
||||
{
|
||||
return $this->ipTtl;
|
||||
}
|
||||
|
||||
public function teapot(): bool
|
||||
{
|
||||
return $this->teapot;
|
||||
}
|
||||
|
||||
public function errorMessage(): string
|
||||
{
|
||||
return $this->errorMessage;
|
||||
}
|
||||
|
||||
public function teapotTitle(): string
|
||||
{
|
||||
return $this->teapotTitle;
|
||||
}
|
||||
|
||||
public function tooManyTitle(): string
|
||||
{
|
||||
return $this->tooManyTitle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use App\Enum\Scope;
|
||||
use Symfony\Component\HttpFoundation\InputBag;
|
||||
|
||||
/** when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||
final class Payload
|
||||
{
|
||||
public string $id; /* session name, identifying who is logging in */
|
||||
public string $token; /* TOTP, typically six digits */
|
||||
public string $nonce; /* random unique string, to block duplicate submissions */
|
||||
public bool $json; /* should we return json (for the login page) */
|
||||
public Scope $scope; /* type of access being requested */
|
||||
|
||||
public static function decode(string $base64url): ?Payload
|
||||
{
|
||||
/* convert the base64url into json string */
|
||||
$base64 = strtr($base64url, '-_', '+/');
|
||||
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
|
||||
$json = base64_decode($base64, true);
|
||||
if ($json) {
|
||||
/* convert the json string into real data */
|
||||
$data = json_decode($json);
|
||||
if (is_object($data)) {
|
||||
return Payload::create($data);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function load(InputBag $input): ?Payload
|
||||
{
|
||||
/* convert form data into real data */
|
||||
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
|
||||
return Payload::create((object)[
|
||||
'id' => $input->get('username'),
|
||||
'nonce' => $input->get('nonce'),
|
||||
'token' => $input->get('totp'),
|
||||
'json' => false,
|
||||
]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function create(object $data): ?Payload
|
||||
{
|
||||
/* if missing required fields id, nonce, or token */
|
||||
if (strlen(trim($data->id ?? '')) < 1 ||
|
||||
strlen(trim($data->nonce ?? '')) < 1 ||
|
||||
strlen(trim($data->token ?? '')) < 1
|
||||
) {
|
||||
/* returns null as the input is invalid */
|
||||
return null;
|
||||
}
|
||||
|
||||
/* 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->json = ($data->json ?? true);
|
||||
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
||||
$payload->token = mb_substr(trim($data->token), 0, 128);
|
||||
|
||||
return Payload::constrict($payload);
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return json_encode($this);
|
||||
}
|
||||
|
||||
private static function constrict(Payload $payload): Payload
|
||||
{
|
||||
/* When scope is None, json will be considered false. */
|
||||
if ($payload->scope === Scope::None) {
|
||||
$payload->json = false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/** scope defines the context of how a session is persisted */
|
||||
enum Scope: string
|
||||
{
|
||||
case Cookie = 'cookie';
|
||||
case Ip = 'ip';
|
||||
case None = 'none';
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Preauth;
|
||||
|
||||
class Env {
|
||||
/**
|
||||
* @return string returns title of this system
|
||||
*/
|
||||
public function getTitle(): string {
|
||||
return getenv('PREAUTH_TITLE')
|
||||
?: 'Pre-Authentication System';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns background-color of this system
|
||||
*/
|
||||
public function getColor(): string {
|
||||
// defaults to teal
|
||||
return getenv('PREAUTH_BACKGROUND') ?: '#029386';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns text-color of this system
|
||||
*/
|
||||
public function getTextColor(): string {
|
||||
// defaults to white
|
||||
return getenv('PREAUTH_FOREGROUND') ?: '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the name of the ID field
|
||||
*/
|
||||
public function getIdName(): string {
|
||||
return getenv('PREAUTH_ID_NAME') ?: 'Session ID';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the name of the Token field
|
||||
*/
|
||||
public function getTokenName(): string {
|
||||
return getenv('PREAUTH_TOKEN_NAME')
|
||||
?: 'Authentication Token';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the name of the Submit button
|
||||
*/
|
||||
public function getSubmitName(): string {
|
||||
return getenv('PREAUTH_SUBMIT_NAME') ?: 'Submit';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied http status code
|
||||
*/
|
||||
public function getDeniedCode(): string {
|
||||
return getenv('PREAUTH_DENIED_CODE') ?: '418';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied response title
|
||||
*/
|
||||
public function getDeniedTitle(): string {
|
||||
return getenv('PREAUTH_DENIED_TITLE')
|
||||
?: "I'm a teapot";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string returns the denied response message
|
||||
*/
|
||||
public function getDeniedMessage(): string {
|
||||
return getenv('PREAUTH_DENIED_MESSAGE')
|
||||
?: 'I refuse to brew coffee.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
|
||||
private PersistCache $persistCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
$this->persistCache = $this->container->get(PersistCache::class);
|
||||
$this->persistCache->boot();
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function terminate(Request $request, Response $response): void
|
||||
{
|
||||
$this->persistCache->persist();
|
||||
|
||||
parent::terminate($request, $response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\Service\DomainInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
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
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
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
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $sessionCache,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class InterceptListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
private Environment $twig,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
#[AsEventListener(priority: 55)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
/* by this point, we know that the request we have is:
|
||||
* not already authorized, nor already rate-limited,
|
||||
* nor submitting login credentials; so redirect or present the login page now */
|
||||
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() &&
|
||||
$this->domainManager->matchesAuth($event->getRequest()->getHost())
|
||||
) {
|
||||
/* host matches base-domain of auth, but not on auth subdomain, redirect */
|
||||
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
|
||||
$event->setResponse(new Response(
|
||||
'',
|
||||
Response::HTTP_SEE_OTHER,
|
||||
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
|
||||
));
|
||||
} else {
|
||||
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
|
||||
$content = $this->twig->render('login.html.twig', [
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
||||
]);
|
||||
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
||||
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
|
||||
);
|
||||
$event->setResponse($this->pruneInvalidCookie(new Response(
|
||||
$content,
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['Content-Type' => 'text/html']
|
||||
), $hasCookie, $event->getRequest()->getHost()));
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
'/',
|
||||
/* if using central auth, only set the domain if the host matches */
|
||||
$this->domainManager->matchesAuth($host) ? $this->domainManager->authBase() : null,
|
||||
true,
|
||||
true,
|
||||
Cookie::SAMESITE_STRICT
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Data\Payload;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\LoginInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
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;
|
||||
|
||||
final readonly class LoginListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
private DomainInterface $domainManager,
|
||||
private LoginInterface $loginManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
|
||||
#[AsEventListener(priority: 66)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$payload = null;
|
||||
$response = null;
|
||||
|
||||
if ($event->getRequest()->headers->has($this->headerName())) {
|
||||
/* if request contains our "X-Preauth" header */
|
||||
$data = $event->getRequest()->headers->get($this->headerName());
|
||||
$payload = Payload::decode($data);
|
||||
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
|
||||
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
|
||||
) {
|
||||
/* if request is a POST to the auth-subdomain */
|
||||
$payload = Payload::load($event->getRequest()->getPayload());
|
||||
} else {
|
||||
/* no login attempt detected */
|
||||
return;
|
||||
}
|
||||
|
||||
if ($payload) {
|
||||
/* user sent a valid payload, check it */
|
||||
$response = $this->loginManager->checkToken($payload, $event->getRequest());
|
||||
|
||||
/* token or backup-code authentication was successful */
|
||||
if ($response) {
|
||||
$event->setResponse($response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* login attempted but unsuccessful, log and block if needed */
|
||||
$limitReached = $this->logFailure($event->getRequest());
|
||||
|
||||
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
|
||||
$event->setResponse($this->makeFailedResponse(
|
||||
$limitReached,
|
||||
$payload->json ?? true,
|
||||
$event->getRequest()->getHost(),
|
||||
$this->makeCacheKey($payload ? $payload->id : '')
|
||||
));
|
||||
}
|
||||
|
||||
private function logFailure(Request $request): bool
|
||||
{
|
||||
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||
return ($limiter->consume(1)->getRemainingTokens() < 1);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
|
||||
{
|
||||
if ($limited) {
|
||||
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
|
||||
: Response::HTTP_TOO_MANY_REQUESTS;
|
||||
$message = $this->config->teapot() ? $this->config->teapotTitle()
|
||||
: $this->config->tooManyTitle();
|
||||
} else {
|
||||
$status = Response::HTTP_UNAUTHORIZED;
|
||||
$message = $this->config->errorMessage();
|
||||
}
|
||||
$answer = [
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $host,
|
||||
'username' => $username,
|
||||
];
|
||||
|
||||
if ($json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode($answer);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = $this->twig->render('login.html.twig', $answer);
|
||||
}
|
||||
|
||||
return new Response($content, $status, ["Content-Type" => $contentType]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Error\SyntaxError;
|
||||
|
||||
final readonly class RejectListener
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
private RateLimiterFactoryInterface $rateLimiter;
|
||||
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private Environment $twig,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
}
|
||||
|
||||
/** @throws SyntaxError|RuntimeError|LoaderError */
|
||||
#[AsEventListener(priority: 77)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
/* check if they have made too many failed login attempts */
|
||||
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
|
||||
if ($limiter->consume(0)->getRemainingTokens() < 1) {
|
||||
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
|
||||
$html = $this->twig->render('error.html.twig');
|
||||
$event->setResponse(new Response(
|
||||
$html,
|
||||
($this->config->teapot()
|
||||
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
|
||||
['Content-Type' => 'text/html']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use OutOfBoundsException;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
|
||||
/* we must *NOT* store the key-list item or values within this object
|
||||
* because it can change from outside this object instance */
|
||||
final readonly class MonitorCacheKeys implements CacheItemPoolInterface
|
||||
{
|
||||
private const string KEY_LIST = '__key_list';
|
||||
private const string CHANGE_LIST = '__chg_list';
|
||||
public const int UPDATED = 1;
|
||||
public const int REMOVED = 2;
|
||||
|
||||
private CacheItemPoolInterface $cache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(CacheItemPoolInterface $cache)
|
||||
{
|
||||
$this->cache = $cache;
|
||||
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
|
||||
foreach ($items as $item) {
|
||||
if (! $item->isHit()) {
|
||||
$this->initialize();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function initialize(): void
|
||||
{
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$keyList->set([]);
|
||||
$changeList->set([]);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->saveDeferred($changeList);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function getKeys(): array
|
||||
{
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
return array_keys($keyList->get() ?? []);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function getChanges(): array
|
||||
{
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
return $changeList->get() ?? [];
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function markClean(): void
|
||||
{
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$changeList->set([]);
|
||||
$this->cache->save($changeList);
|
||||
}
|
||||
|
||||
public function getItem(string $key): CacheItemInterface
|
||||
{
|
||||
return $this->cache->getItem($key);
|
||||
}
|
||||
|
||||
/** @return CacheItemInterface[]
|
||||
* @throws InvalidArgumentException */
|
||||
public function getItems(array $keys = []): iterable
|
||||
{
|
||||
return $this->cache->getItems($keys);
|
||||
}
|
||||
|
||||
public function hasItem(string $key): bool
|
||||
{
|
||||
return $this->cache->hasItem($key);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function clear(): bool
|
||||
{
|
||||
/* only bother clearing the pool if it is not empty */
|
||||
if (! empty($this->getKeys())) {
|
||||
$response = $this->cache->clear();
|
||||
|
||||
$this->initialize();
|
||||
return $response;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function deleteItem(string $key): bool
|
||||
{
|
||||
$this->isValid($key);
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$keyValues = $keyList->get();
|
||||
if (isset($keyValues[$key])) {
|
||||
unset($keyValues[$key]);
|
||||
$keyList->set($keyValues);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
return $this->cache->deleteItem($key);
|
||||
}
|
||||
|
||||
public function deleteItems(array $keys): bool
|
||||
{
|
||||
$this->allValid($keys);
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$keyValues = $keyList->get();
|
||||
foreach ($keys as $key) {
|
||||
if (isset($keyValues[$key])) {
|
||||
unset($keyValues[$key]);
|
||||
$this->logChange($key, MonitorCacheKeys::REMOVED);
|
||||
}
|
||||
}
|
||||
$keyList->set($keyValues);
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->commit();
|
||||
|
||||
return $this->cache->deleteItems($keys);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function save(CacheItemInterface $item): bool
|
||||
{
|
||||
$this->update($item);
|
||||
return $this->cache->save($item);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function saveDeferred(CacheItemInterface $item): bool
|
||||
{
|
||||
$this->update($item);
|
||||
return $this->cache->saveDeferred($item);
|
||||
}
|
||||
|
||||
public function commit(): bool
|
||||
{
|
||||
return $this->cache->commit();
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|OutOfBoundsException */
|
||||
private function update(CacheItemInterface $item): void
|
||||
{
|
||||
$this->isValid($item->getKey());
|
||||
$keyList = $this->cache->getItem(self::KEY_LIST);
|
||||
$keyValues = $keyList->get();
|
||||
$keyValues[$item->getKey()] = true;
|
||||
$keyList->set($keyValues);
|
||||
$this->logChange($item->getKey());
|
||||
$this->cache->saveDeferred($keyList);
|
||||
$this->cache->commit();
|
||||
}
|
||||
|
||||
/** @throws OutOfBoundsException */
|
||||
private function isValid(string $key): void
|
||||
{
|
||||
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not modify the private key or change lists'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws OutOfBoundsException */
|
||||
private function allValid(array $keys): void
|
||||
{
|
||||
if (in_array(self::KEY_LIST, $keys, true) ||
|
||||
in_array(self::CHANGE_LIST, $keys, true)
|
||||
) {
|
||||
throw new OutOfBoundsException(
|
||||
'Can not modify the private key or change lists'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void
|
||||
{
|
||||
$changeList = $this->cache->getItem(self::CHANGE_LIST);
|
||||
$changeValues = $changeList->get();
|
||||
$changeValues[$key] = $code;
|
||||
$changeList->set($changeValues);
|
||||
$this->cache->saveDeferred($changeList);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
|
||||
/* need autoconfigure so we get it from the service container in Kernel->boot() */
|
||||
#[Autoconfigure(public: true)]
|
||||
final readonly class PersistCache
|
||||
{
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
private MonitorCacheKeys $sessionStorage;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
CacheItemPoolInterface $sessionStorage,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function boot(): void
|
||||
{
|
||||
/* the caches are considered warm as soon as they are not empty */
|
||||
if (empty($this->sessionCache->getKeys())) {
|
||||
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
|
||||
foreach ($items as $item) {
|
||||
$this->sessionCache->saveDeferred($item);
|
||||
}
|
||||
$this->sessionCache->markClean();
|
||||
$this->sessionCache->commit();
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function persist(): void
|
||||
{
|
||||
/* we only need to persist the changes made to the cache (if any) */
|
||||
$changes = $this->sessionCache->getChanges();
|
||||
if ($changes) {
|
||||
$this->sessionCache->markClean();
|
||||
$items = $this->sessionCache->getItems(array_keys($changes));
|
||||
foreach ($items as $item) {
|
||||
if (($changes[$item->getKey()] ?? null) === MonitorCacheKeys::REMOVED) {
|
||||
$this->sessionStorage->deleteItem($item->getKey());
|
||||
} else {
|
||||
$this->sessionStorage->saveDeferred($item);
|
||||
}
|
||||
}
|
||||
$this->sessionStorage->commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Exception;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
|
||||
/** backup-codes are case‑insensitive alphanumeric strings
|
||||
* they are single-use and marked as used after successful authentication */
|
||||
interface BackupCodeInterface
|
||||
{
|
||||
/** generate a set of backup-codes and return them
|
||||
* @param int $count Number of codes to generate
|
||||
* @return string[] Generated backup codes
|
||||
* @throws InvalidArgumentException|Exception */
|
||||
public function generate(int $count = 0): array;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function expire(): void;
|
||||
|
||||
/** check if backup-code is valid and mark it as used
|
||||
* @param string $code Code supplied by the client
|
||||
* @return bool true if the code is valid and unused
|
||||
* @throws InvalidArgumentException */
|
||||
public function verifyAndConsume(string $code): bool;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use App\Trait\GetTotpTrait;
|
||||
|
||||
/** backup-codes are case‑insensitive alphanumeric strings
|
||||
* they are single-use and marked as used after successful authentication */
|
||||
final readonly class BackupCodeManager implements BackupCodeInterface
|
||||
{
|
||||
use GetTotpTrait;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
private const int DEFAULT_COUNT = 10;
|
||||
/* php base_convert() will break if given too long of an input */
|
||||
public const int MAX_LENGTH = 64;
|
||||
|
||||
private CacheItemPoolInterface $sessionCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(CacheItemPoolInterface $sessionCache)
|
||||
{
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/** generate a set of backup-codes and return them
|
||||
* @param int $count Number of codes to generate
|
||||
* @return string[] Generated backup codes
|
||||
* @throws InvalidArgumentException|Exception */
|
||||
public function generate(int $count = self::DEFAULT_COUNT): array
|
||||
{
|
||||
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
/* output is alphanumeric string of given length */
|
||||
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
|
||||
random_bytes($length)
|
||||
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
|
||||
}
|
||||
$this->saveCodes($codes);
|
||||
$this->logger->info("generated {$count} backup codes");
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function expire(): void
|
||||
{
|
||||
$itemsToRemove = [];
|
||||
foreach ($this->sessionCache->getKeys() as $key) {
|
||||
if (str_starts_with($key, 'backup_')) {
|
||||
$itemsToRemove[] = $key;
|
||||
}
|
||||
}
|
||||
if (count($itemsToRemove) > 0) {
|
||||
$this->sessionCache->deleteItems($itemsToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
/** check if backup-code is valid and mark it as used
|
||||
* @param string $code Code supplied by the client
|
||||
* @return bool true if the code is valid and unused
|
||||
* @throws InvalidArgumentException */
|
||||
public function verifyAndConsume(string $code): bool
|
||||
{
|
||||
/* 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'));
|
||||
if ($backupItem->isHit() && $backupItem->get()) {
|
||||
$this->logger->debug("valid backup code");
|
||||
/* mark backup code as spent */
|
||||
$backupItem->set(false); /* used */
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
));
|
||||
$this->sessionCache->save($backupItem);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function saveCodes(array $codes): void
|
||||
{
|
||||
foreach ($codes as $code) {
|
||||
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
|
||||
/* mark backup code as ready */
|
||||
$backupItem->set(true);
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
));
|
||||
$this->sessionCache->saveDeferred($backupItem);
|
||||
}
|
||||
$this->sessionCache->commit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
interface DomainInterface
|
||||
{
|
||||
/** IE: "auth.example.com" or null if not using a separate subdomain
|
||||
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||
public function getAuthSubdomain(): ?string;
|
||||
|
||||
/** check if given url is an acceptable url for redirection
|
||||
* @param string $url Where we are thinking of sending the user
|
||||
* @return bool Returns true if it is acceptable to send the user there */
|
||||
public function validReturn(string $url): bool;
|
||||
|
||||
/** check if host-base matches auth-base
|
||||
* @param string $host
|
||||
* @return bool returns true if and only if host matches base domain of auth */
|
||||
public function matchesAuth(string $host): bool;
|
||||
|
||||
/** IE: "example.com" if central auth is something like "auth.example.com"
|
||||
* @return string|null returns base domain if we are doing central auth */
|
||||
public function authBase(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class DomainManager implements DomainInterface
|
||||
{
|
||||
/* top-level-domains which are known to have multiple parts */
|
||||
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'],
|
||||
'de' => ['com'],
|
||||
'fm' => ['radio'],
|
||||
'gg' => ['co','net','org'],
|
||||
'in' => ['co','firm','gen','ind','net','org'],
|
||||
'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'],
|
||||
];
|
||||
|
||||
private bool $subdomainRedirect;
|
||||
private string $authSubdomain;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
|
||||
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
|
||||
) {
|
||||
$this->subdomainRedirect = $subdomainRedirect;
|
||||
$this->authSubdomain = $authSubdomain;
|
||||
}
|
||||
|
||||
/** IE: "auth.example.com" or null if not using a separate subdomain
|
||||
* @return ?string Returns auth subdomain if configured, otherwise null */
|
||||
public function getAuthSubdomain(): ?string
|
||||
{
|
||||
if ($this->authBase()) {
|
||||
return $this->authSubdomain;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** check if given url is an acceptable url for redirection
|
||||
* @param string $url Where we are thinking of sending the user
|
||||
* @return bool Returns true if it is acceptable to send the user there */
|
||||
public function validReturn(string $url): bool
|
||||
{
|
||||
/* ensure url is valid and, when using an auth subdomain,
|
||||
* that the url host matches the base domain */
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->authBase()) {
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === null) {
|
||||
return false;
|
||||
}
|
||||
/* do not send the user to another domain */
|
||||
return $this->matchesAuth($host);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** check if host-base matches auth-base
|
||||
* @param string $host
|
||||
* @return bool returns true if and only if host matches base domain of auth */
|
||||
public function matchesAuth(string $host): bool
|
||||
{
|
||||
$hostBase = $this->baseDomain($host);
|
||||
$authBase = $this->baseDomain($this->authSubdomain);
|
||||
return $this->subdomainRedirect && $this->authSubdomain &&
|
||||
$authBase && $authBase === $hostBase;
|
||||
}
|
||||
|
||||
/** IE: "example.com" if central auth is something like "auth.example.com"
|
||||
* @return string|null returns base domain if we are doing central auth */
|
||||
public function authBase(): ?string
|
||||
{
|
||||
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
|
||||
return $this->baseDomain($this->authSubdomain);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** this lets us determine the base domain of the given ip, localhost, or domain
|
||||
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
|
||||
* things like "localhost" and "8.8.8.8" will return null
|
||||
* @param string $host ip, localhost, or domain with zero or more subdomains
|
||||
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
|
||||
private function baseDomain(string $host): ?string
|
||||
{
|
||||
/* if host is an ip address (or localhost), leave it as is */
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = explode('.', $host);
|
||||
$keep = $this->baseLength($parts);
|
||||
$parts = array_slice($parts, -$keep);
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
|
||||
* @param string[] $parts pieces of a domain split by "." dot
|
||||
* @return int typically 2 but sometimes 3 */
|
||||
private function baseLength(array $parts): int
|
||||
{
|
||||
$length = count($parts);
|
||||
$baseLength = min(2, $length);
|
||||
/* check if host should retain 3 parts, due to TLD */
|
||||
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
|
||||
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
|
||||
) {
|
||||
$baseLength = min(3, $length);
|
||||
}
|
||||
return $baseLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
interface LoginInterface
|
||||
{
|
||||
/** @throws InvalidArgumentException */
|
||||
public function checkToken(Payload $payload, Request $request): ?Response;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
final readonly class LoginManager implements LoginInterface
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use GetTotpTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
|
||||
private CacheItemPoolInterface $sessionCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
CacheItemPoolInterface $sessionCache,
|
||||
private BackupCodeInterface $backupCodeManager,
|
||||
private DomainInterface $domainManager,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function checkToken(Payload $payload, Request $request): ?Response
|
||||
{
|
||||
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
|
||||
/* requested to grant ip access, but that is not enabled */
|
||||
$payload->scope = Scope::Cookie;
|
||||
}
|
||||
|
||||
if ($this->getTotp()->verify($payload->token, null, 10) ||
|
||||
$this->backupCodeManager->verifyAndConsume($payload->token)
|
||||
) {
|
||||
/* token is correct (TOTP or Backup) */
|
||||
|
||||
/* if server nonce is found and is valid */
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
|
||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */
|
||||
$this->nonceCache->save($nonceItem);
|
||||
|
||||
/* token authentication successful, grant access and set response */
|
||||
$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,
|
||||
]);
|
||||
|
||||
if ($payload->scope !== Scope::None) {
|
||||
/* grant access based on the requested scope */
|
||||
if ($payload->scope === Scope::Cookie) {
|
||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||
} elseif ($payload->scope === Scope::Ip) {
|
||||
$this->setIp($cleanId, $request->getClientIp());
|
||||
}
|
||||
|
||||
if ($payload->json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode([
|
||||
'message' => 'Login successful',
|
||||
'nonce' => null,
|
||||
]);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = "hi $cleanId, please reload";
|
||||
}
|
||||
|
||||
$location = $request->query->has('return') &&
|
||||
$this->domainManager->validReturn($request->query->get('return')) ?
|
||||
"{$request->query->get('return')}" :
|
||||
"{$request->getPathInfo()}{$request->getQueryString()}";
|
||||
|
||||
/* force redirect to use GET method (important when using central auth) */
|
||||
$response->setContent($content)
|
||||
->setStatusCode(Response::HTTP_SEE_OTHER)
|
||||
->headers->set('Location', $location);
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
}
|
||||
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setCookie(string $id, string $host): Cookie
|
||||
{
|
||||
/* successful auth with token, store session and set the cookie */
|
||||
$ulid = new Ulid();
|
||||
$sessionCookie = $this->sessionCache->getItem(
|
||||
$this->makeCacheKey("cookie_$ulid")
|
||||
);
|
||||
if ($sessionCookie->isHit()) {
|
||||
/* it is supposed to be impossible to have collisions */
|
||||
$this->logger->error("aborting: ULID collision");
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
}
|
||||
$sessionCookie->set($id);
|
||||
$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(),
|
||||
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,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: Cookie::SAMESITE_STRICT,
|
||||
);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setIp(string $id, string $ip): void
|
||||
{
|
||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||
|
||||
$sessionIp = $this->sessionCache->getItem($ipKey);
|
||||
$sessionIp->set($id);
|
||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
||||
$this->sessionCache->save($sessionIp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
trait CookieNameTrait
|
||||
{
|
||||
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
||||
private const string HEADER_NAME = 'X-Preauth';
|
||||
|
||||
final protected function cookieName(): string
|
||||
{
|
||||
return static::COOKIE_NAME;
|
||||
}
|
||||
|
||||
final protected function authCookieName(): string
|
||||
{
|
||||
return static::AUTH_COOKIE_NAME;
|
||||
}
|
||||
|
||||
final protected function headerName(): string
|
||||
{
|
||||
return static::HEADER_NAME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
use App\ConfigBag;
|
||||
use OTPHP\Factory;
|
||||
use OTPHP\TOTPInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait GetTotpTrait
|
||||
{
|
||||
protected readonly ConfigBag $config;
|
||||
|
||||
#[Required]
|
||||
public function setConfig(ConfigBag $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
protected function getTotp(): TOTPInterface
|
||||
{
|
||||
$otp = Factory::loadFromProvisioningUri(
|
||||
$this->config->totpUri(),
|
||||
$this->config->clock()
|
||||
);
|
||||
if ($otp instanceof TOTPInterface) {
|
||||
return $otp;
|
||||
}
|
||||
throw new HttpException(500, 'Internal Server Exception');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait HasLoggerTrait
|
||||
{
|
||||
protected readonly LoggerInterface $logger;
|
||||
|
||||
#[Required]
|
||||
public function setLogger(LoggerInterface $logger): void
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
use Exception;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
trait MakeNonceTrait
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
/* 15 bytes neatly fits in base64 */
|
||||
private const int NONCE_LENGTH = 15;
|
||||
private const int NONCE_TTL = 120;
|
||||
|
||||
protected readonly CacheItemPoolInterface $nonceCache;
|
||||
|
||||
#[Required]
|
||||
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
|
||||
{
|
||||
$this->nonceCache = $nonceCache;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|Exception */
|
||||
protected function makeNonce(int $retries = 3): string
|
||||
{
|
||||
/* convert raw binary into base64url */
|
||||
$nonce = rtrim(strtr(base64_encode(random_bytes(
|
||||
static::NONCE_LENGTH
|
||||
)), '+/', '-_'), '=');
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
|
||||
|
||||
if ($nonceItem->isHit()) {
|
||||
if ($retries < 1) {
|
||||
$this->logger->error("aborting: multiple nonce collisions");
|
||||
throw new HttpException(
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR,
|
||||
'Internal Server Error'
|
||||
);
|
||||
}
|
||||
/* managed to have a collision, try again */
|
||||
return $this->makeNonce($retries - 1);
|
||||
}
|
||||
|
||||
$nonceItem->set(true); /* valid */
|
||||
$nonceItem->expiresAfter(static::NONCE_TTL);
|
||||
$this->logger->debug("added nonce: $nonce");
|
||||
$this->nonceCache->save($nonceItem);
|
||||
return $nonce;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Trait;
|
||||
|
||||
trait StringTrait
|
||||
{
|
||||
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
|
||||
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
|
||||
|
||||
public function makeCacheKey(string $name): string
|
||||
{
|
||||
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use BaconQrCode\Renderer\PlainTextRenderer;
|
||||
use BaconQrCode\Writer;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
final readonly class Utilities
|
||||
{
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private CacheItemPoolInterface $appPool,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function loadTotp(): string
|
||||
{
|
||||
/* user forgot to set their TOTP_URI in the environment */
|
||||
if ($this->appPool->hasItem('totp')) {
|
||||
$totp = $this->appPool->getItem('totp')->get();
|
||||
} else {
|
||||
$totp = $this->makeTotp();
|
||||
}
|
||||
|
||||
$this->showTotp($totp);
|
||||
return $totp;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function makeTotp(): string
|
||||
{
|
||||
/* we have not stored a totp into the app cache yet */
|
||||
$totpObj = TOTP::generate($this->clock);
|
||||
$totpObj->setLabel('Preauth-TOTP');
|
||||
$totp = $totpObj->getProvisioningUri();
|
||||
$totpItem = $this->appPool->getItem('totp');
|
||||
$totpItem->set($totp);
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d',
|
||||
'2999-12-31'
|
||||
));
|
||||
$this->appPool->save($totpItem);
|
||||
return $totp;
|
||||
}
|
||||
|
||||
private function showTotp(string $totp): void
|
||||
{
|
||||
$writer = new Writer(new PlainTextRenderer());
|
||||
file_put_contents(
|
||||
'php://stderr',
|
||||
<<<RAW
|
||||
{$writer->writeString($totp)}
|
||||
$totp
|
||||
loading TOTP, because the env is not set, please copy above into TOTP_URI
|
||||
|
||||
RAW,
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"friendsofphp/php-cs-fixer": {
|
||||
"version": "3.95",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.39",
|
||||
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
|
||||
},
|
||||
"files": [
|
||||
".php-cs-fixer.dist.php"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "11.1",
|
||||
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||
},
|
||||
"files": [
|
||||
".env.test",
|
||||
"phpunit.dist.xml",
|
||||
"tests/bootstrap.php",
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "2.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.4",
|
||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||
},
|
||||
"files": [
|
||||
".env",
|
||||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php",
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
use Preauth\Auth;
|
||||
use Preauth\Env;
|
||||
global $auth;
|
||||
$env = new Env();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo $env->getTitle(); ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Begin Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
|
||||
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
|
||||
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
|
||||
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
|
||||
<!-- End Icons -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
}
|
||||
html {
|
||||
background-color: <?php echo $env->getColor(); ?>;
|
||||
color: <?php echo $env->getTextColor(); ?>;
|
||||
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;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
form div {
|
||||
width: 45%;
|
||||
}
|
||||
div.right {
|
||||
text-align: right;
|
||||
}
|
||||
div.center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><?php echo $env->getTitle(); ?></h1>
|
||||
<form action="/" method="get">
|
||||
<input type="hidden" name="<?php echo Auth::RETURN_FIELD; ?>" value="<?php echo $auth->getReturnTo(); ?>">
|
||||
<div class="right"><label for="id"><?php echo $env->getIdName(); ?>:</label></div>
|
||||
<div><input type="text" name="<?php echo Auth::ID_FIELD; ?>" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
|
||||
<div class="right"><label for="token"><?php echo $env->getTokenName(); ?>:</label></div>
|
||||
<div><input type="text" name="<?php echo Auth::TOKEN_FIELD; ?>" id="token" autocomplete="off" required="required"></div>
|
||||
<div class="center"><button type="submit"><?php echo $env->getSubmitName(); ?></button></div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script>
|
||||
const form = document.getElementById('preauth-form');
|
||||
const message = document.getElementById('preauth-message');
|
||||
const body = document.getElementById('preauth-body');
|
||||
const style = document.getElementById('preauth-style');
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
{# make base64url string containing our payload json object #}
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.username.value?.trim() ?? '',
|
||||
token: form.totp.value?.trim() ?? '',
|
||||
nonce: form.nonce.value?.trim() ?? '',
|
||||
json: true
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
{# send our request to the server #}
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: { 'X-Preauth': data },
|
||||
}).then((response) => {
|
||||
{% if env.debug > 2 -%}
|
||||
console.log(response);
|
||||
{% endif -%}
|
||||
if (response.headers.has('Location')) {
|
||||
{# follow redirect (probably not needed) #}
|
||||
{% if env.debug > 2 -%}
|
||||
console.log('got redirect response');
|
||||
{% endif -%}
|
||||
window.location.href = response.headers.get('Location');
|
||||
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
|
||||
{# got json, update the page #}
|
||||
{% if env.debug > 2 -%}
|
||||
console.log('got json response');
|
||||
{% endif -%}
|
||||
response.json().then((content) => {
|
||||
if (Object.hasOwn(content, 'message')) {
|
||||
message.innerText = content.message;
|
||||
}
|
||||
if (Object.hasOwn(content, 'nonce')) {
|
||||
form.nonce.value = content.nonce;
|
||||
form.totp.value = '';
|
||||
form.totp.focus();
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.log('failed to parse json from response');
|
||||
console.log(error);
|
||||
});
|
||||
} else if (response.headers.get('Content-Type')?.toLowerCase().includes('text/html') ?? false) {
|
||||
{# got html, replace the page #}
|
||||
{% if env.debug > 2 -%}
|
||||
console.log('got html response');
|
||||
{% endif -%}
|
||||
response.text().then((html) => {
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
}).catch((error) => {
|
||||
console.log('failed to get html from response');
|
||||
console.log(error);
|
||||
});
|
||||
} else {
|
||||
{# non-json, non-html, non-redirect response #}
|
||||
{# update the page, change style to plain text #}
|
||||
{% if env.debug > 2 -%}
|
||||
console.log('got misc response');
|
||||
{% endif -%}
|
||||
response.text().then((text) => {
|
||||
body.innerText = text;
|
||||
style.disabled = true;
|
||||
body.style.whiteSpace = 'pre-wrap';
|
||||
body.style.wordWrap = 'break-word';
|
||||
}).catch((error) => {
|
||||
console.log('failed to get text from response');
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.log('failed to get response');
|
||||
console.log(error);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<style id="preauth-style">
|
||||
* { margin: 0; padding: 0.25em; }
|
||||
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; 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; }
|
||||
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 }
|
||||
div.center { text-align: center; }
|
||||
div.hidden { display: none; }
|
||||
span { cursor: pointer; font-size: 0.75em; text-decoration: underline; }
|
||||
button { background-color: #cccccc; }
|
||||
input { background-color: #ffffff; max-width: 100%; }
|
||||
button, input { border: 0.0625em solid #333333; border-radius: 0.25em; color: #333333; font-size: 0.9em; }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ env.title }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
{{- include('_style.html.twig') -}}
|
||||
</head>
|
||||
<body id="preauth-body">
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% if env.teapot|default(true) %}
|
||||
<h1>{{ env.teapot_title }}</h1>
|
||||
<p>{{ env.teapot_message }}</p>
|
||||
{% else %}
|
||||
<h1>{{ env.too_many_title }}</h1>
|
||||
<p>{{ env.too_many_message }}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ env.title }}</h1>
|
||||
<p id="preauth-message">{{ message|default }}</p>
|
||||
<form id="preauth-form" {% if post ?? false -%} method="post" {%- endif %}>
|
||||
<input id="nonce" type="hidden" name="nonce" value="{{ nonce }}">
|
||||
<div class="right"><label for="username">{{ env.id_name }}:</label></div>
|
||||
<div><input type="text" name="username" id="username" {% if username ?? false %}value="{{ username }}"{% endif %}
|
||||
autocomplete="username" required="required" autofocus="autofocus"></div>
|
||||
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
|
||||
<div><input type="text" name="totp" id="totp"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
|
||||
</form>
|
||||
{% if not post ?? false %}
|
||||
{{- include('_script.html.twig') -}}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,514 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use OTPHP\TOTP;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* End-to-end functional tests exercising the full HTTP kernel: the request
|
||||
* travels through RejectListener -> LoginListener -> AllowListener ->
|
||||
* AcceptListener -> InterceptListener and the services they orchestrate.
|
||||
*/
|
||||
final class AuthenticationFlowTest extends WebTestCase
|
||||
{
|
||||
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||
|
||||
protected static function createClient(array $options = [], array $server = []): KernelBrowser
|
||||
{
|
||||
$client = parent::createClient($options, $server);
|
||||
// The app stores nonces in the (in-memory) nonceCache pool. In
|
||||
// production APCu keeps them across requests, but KernelBrowser
|
||||
// reboots the kernel between requests by default which would lose
|
||||
// them. Disable the reboot so the nonce issued on the login-page
|
||||
// request survives to the login-submission request.
|
||||
$client->disableReboot();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function validTotpCode(): string
|
||||
{
|
||||
// the app uses the real system clock, so generate the code for now()
|
||||
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
|
||||
}
|
||||
|
||||
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
|
||||
private function encodePayload(array $data): string
|
||||
{
|
||||
$json = json_encode($data, JSON_THROW_ON_ERROR);
|
||||
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function loginPayload(
|
||||
string $id = 'testuser',
|
||||
?string $token = null,
|
||||
string $nonce = 'test-nonce-abc',
|
||||
bool $json = true,
|
||||
): string {
|
||||
return $this->encodePayload([
|
||||
'id' => $id,
|
||||
'token' => $token ?? $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => $json,
|
||||
]);
|
||||
}
|
||||
|
||||
/* ── unauthenticated access ──────────────────────────────────────── */
|
||||
|
||||
public function testUnauthenticatedRequestShowsLoginPage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/');
|
||||
|
||||
// login page is served with 401 (Unauthorized) to signal the proxy
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
self::assertSelectorExists('form#preauth-form');
|
||||
self::assertSelectorExists('input[name="nonce"]');
|
||||
self::assertSelectorExists('input[name="username"]');
|
||||
self::assertSelectorExists('input[name="totp"]');
|
||||
}
|
||||
|
||||
public function testLoginPageContainsGeneratedNonce(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$crawler = $client->request('GET', '/');
|
||||
|
||||
$nonceInput = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
self::assertNotEmpty($nonceInput);
|
||||
// base64url charset
|
||||
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
|
||||
}
|
||||
|
||||
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$crawler = $client->request('GET', '/');
|
||||
|
||||
$form = $crawler->filter('form#preauth-form');
|
||||
// without central auth, the form should NOT have method="post"
|
||||
$method = $form->attr('method');
|
||||
self::assertNull($method);
|
||||
}
|
||||
|
||||
/* ── successful TOTP login ────────────────────────────────────────── */
|
||||
|
||||
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// first, grab a valid nonce from the login page
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
self::assertNotEmpty($nonce);
|
||||
|
||||
// now submit a valid TOTP via the X-Preauth header
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(303, $response->getStatusCode()); // SEE_OTHER
|
||||
self::assertTrue($response->headers->has('Location'));
|
||||
// a session cookie should be set
|
||||
$cookies = $response->headers->getCookies();
|
||||
$hasPreauthCookie = false;
|
||||
foreach ($cookies as $cookie) {
|
||||
if (str_contains($cookie->getName(), 'Preauth')) {
|
||||
$hasPreauthCookie = true;
|
||||
}
|
||||
}
|
||||
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'bob',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(303, $response->getStatusCode());
|
||||
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
$body = json_decode($response->getContent(), true);
|
||||
self::assertSame('Login successful', $body['message']);
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'carol',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => false,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(303, $response->getStatusCode());
|
||||
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function testAuthenticatedCookieAccessAfterLogin(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// login
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'dave',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
// grab the cookie value from the login response
|
||||
$loginResponse = $client->getResponse();
|
||||
$cookieValue = null;
|
||||
foreach ($loginResponse->headers->getCookies() as $cookie) {
|
||||
if (str_contains($cookie->getName(), 'Preauth')) {
|
||||
$cookieValue = $cookie->getValue();
|
||||
}
|
||||
}
|
||||
self::assertNotNull($cookieValue);
|
||||
|
||||
// the cookie was set with secure=true, so the CookieJar will only
|
||||
// send it over HTTPS; the KernelBrowser automatically updates the
|
||||
// CookieJar from the login response, so the next request over HTTPS
|
||||
// will include it
|
||||
$client->request('GET', 'https://localhost/dashboard');
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('dave', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'eve',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'scope' => 'none',
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
|
||||
self::assertSame('eve', $response->headers->get('Remote-User'));
|
||||
// no redirect for scope=none
|
||||
self::assertFalse($response->headers->has('Location'));
|
||||
}
|
||||
|
||||
/* ── failed login ─────────────────────────────────────────────────── */
|
||||
|
||||
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'token' => '000000', // wrong code
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(401, $response->getStatusCode());
|
||||
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
$body = json_decode($response->getContent(), true);
|
||||
self::assertArrayHasKey('message', $body);
|
||||
self::assertArrayHasKey('nonce', $body);
|
||||
// a fresh nonce should be returned for the next attempt
|
||||
self::assertNotEmpty($body['nonce']);
|
||||
}
|
||||
|
||||
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'token' => 'wrong-code',
|
||||
'nonce' => $nonce,
|
||||
'json' => false,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(401, $response->getStatusCode());
|
||||
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||
self::assertSelectorExists('form#preauth-form');
|
||||
}
|
||||
|
||||
public function testFailedLoginWithSpentNonceIsRejected(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
// first: successful login consumes the nonce
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||
|
||||
// the successful login set a session cookie; clear it so the next
|
||||
// request is not auto-authenticated by AcceptListener before the
|
||||
// login attempt is even evaluated
|
||||
$client->getCookieJar()->clear();
|
||||
|
||||
// reuse the same nonce — should fail even with a valid token
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testFailedLoginWithInvalidNonceIsRejected(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// skip fetching a real nonce; use one that was never stored
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice',
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => 'never-issued-nonce',
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── invalid payload ──────────────────────────────────────────────── */
|
||||
|
||||
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => '!!!not-valid-base64!!!',
|
||||
]);
|
||||
|
||||
// decode fails -> null payload -> failure path -> 401
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// payload missing token
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice', 'nonce' => 'some-nonce',
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── invalid cookie ───────────────────────────────────────────────── */
|
||||
|
||||
public function testInvalidCookieIsClearedAndLoginPageShown(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// the cookie must be set via the CookieJar so that the HttpFoundation
|
||||
// Request actually populates its cookies bag (HTTP_COOKIE alone is
|
||||
// not parsed by Request::create)
|
||||
$client->getCookieJar()->set(
|
||||
new \Symfony\Component\BrowserKit\Cookie(
|
||||
self::COOKIE_NAME,
|
||||
'invalid-ulid-value',
|
||||
null,
|
||||
'/',
|
||||
'localhost',
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
'Strict',
|
||||
)
|
||||
);
|
||||
|
||||
$client->request('GET', 'https://localhost/');
|
||||
|
||||
$response = $client->getResponse();
|
||||
// not authenticated -> login page with 401
|
||||
self::assertSame(401, $response->getStatusCode());
|
||||
// the stale cookie should be cleared
|
||||
$cleared = false;
|
||||
foreach ($response->headers->getCookies() as $cookie) {
|
||||
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
|
||||
$cleared = true;
|
||||
}
|
||||
}
|
||||
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
|
||||
}
|
||||
|
||||
/* ── backup code authentication ───────────────────────────────────── */
|
||||
|
||||
public function testBackupCodeAuthenticationWorks(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$container = $client->getContainer();
|
||||
|
||||
// generate a backup code via the BackupCodeManager
|
||||
$manager = $container->get(\App\Service\BackupCodeInterface::class);
|
||||
$codes = $manager->generate(1);
|
||||
self::assertCount(1, $codes);
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'frank',
|
||||
'token' => $codes[0],
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testConsumedBackupCodeCannotBeReused(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$container = $client->getContainer();
|
||||
|
||||
$manager = $container->get(\App\Service\BackupCodeInterface::class);
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// first use
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'frank', 'token' => $code, 'nonce' => $nonce, 'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||
|
||||
// the successful login set a session cookie; clear it so the next
|
||||
// request reaches the login page instead of being auto-authenticated
|
||||
$client->getCookieJar()->clear();
|
||||
|
||||
// second use with a fresh nonce
|
||||
$crawler = $client->request('GET', '/');
|
||||
$nonce2 = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
$client->request('GET', '/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'frank', 'token' => $code, 'nonce' => $nonce2, 'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── return URL handling ──────────────────────────────────────────── */
|
||||
|
||||
public function testSuccessfulLoginWithValidReturnUrl(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/?return=https://example.com/app');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/?return=https://example.com/app', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice', 'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce, 'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(303, $response->getStatusCode());
|
||||
self::assertSame('https://example.com/app', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$crawler = $client->request('GET', '/?return=not-a-url');
|
||||
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
|
||||
$client->request('GET', '/?return=not-a-url', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => 'alice', 'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce, 'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(303, $response->getStatusCode());
|
||||
$location = $response->headers->get('Location');
|
||||
// should fall back to the request path (with query string),
|
||||
// not redirect to the invalid return URL as an absolute URL
|
||||
self::assertStringStartsWith('/', $location);
|
||||
// the invalid return URL is not used as the redirect target
|
||||
self::assertStringNotContainsString('//not-a-url', $location);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Service\DomainManager;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\RateLimiter\RateLimit;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
use Symfony\Component\RateLimiter\LimiterInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
/**
|
||||
* Helpers for constructing the collaborators that the kernel listeners
|
||||
* depend on, without booting the full Symfony container.
|
||||
*/
|
||||
trait ListenerTestHelper
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
/** Build a Twig Environment pointed at the project's real templates. */
|
||||
private function makeTwig(): Environment
|
||||
{
|
||||
$loader = new FilesystemLoader(dirname(__DIR__, 2) . '/templates');
|
||||
$twig = new Environment($loader, ['strict_variables' => true]);
|
||||
// the templates reference a global `env` object; supply one with the
|
||||
// keys used by base/login/error/_script/_style
|
||||
$twig->addGlobal('env', (object)[
|
||||
'title' => 'Pre-Authentication System',
|
||||
'bg_color' => '#029386',
|
||||
'fg_color' => '#ffffff',
|
||||
'error_color' => '#ffb16d',
|
||||
'id_name' => 'Session ID',
|
||||
'token_name' => 'Authentication Token',
|
||||
'submit_name' => 'Submit',
|
||||
'error_message' => 'Unsuccessful login attempt',
|
||||
'teapot' => true,
|
||||
'teapot_title' => "I'm a teapot",
|
||||
'teapot_message' => 'I refuse to brew coffee',
|
||||
'too_many_title' => 'Too many requests',
|
||||
'too_many_message' => 'Try again later',
|
||||
'debug' => 0,
|
||||
]);
|
||||
return $twig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A RateLimiterFactoryInterface whose created limiter returns a RateLimit
|
||||
* with the given remaining tokens.
|
||||
*/
|
||||
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
|
||||
{
|
||||
$limiter = $this->makeLimiter($remainingTokens);
|
||||
return new class ($limiter) implements RateLimiterFactoryInterface {
|
||||
public function __construct(private LimiterInterface $limiter)
|
||||
{
|
||||
}
|
||||
public function create(?string $key = null): LimiterInterface
|
||||
{
|
||||
return $this->limiter;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function makeLimiter(int $remainingTokens): LimiterInterface
|
||||
{
|
||||
$rateLimit = new RateLimit(
|
||||
$remainingTokens,
|
||||
new \DateTimeImmutable('+10 seconds'),
|
||||
$remainingTokens > 0,
|
||||
10,
|
||||
);
|
||||
return new class ($rateLimit) implements LimiterInterface {
|
||||
public function __construct(private RateLimit $rateLimit)
|
||||
{
|
||||
}
|
||||
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||
{
|
||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
||||
}
|
||||
public function consume(int $tokens = 1): RateLimit
|
||||
{
|
||||
return $this->rateLimit;
|
||||
}
|
||||
public function reset(): void
|
||||
{
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A factory whose limiter tracks how many consume(1) calls were made and
|
||||
* reports the limit as reached only after $threshold failures.
|
||||
*/
|
||||
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
|
||||
{
|
||||
$limiter = new class ($threshold) implements LimiterInterface {
|
||||
private int $consumed = 0;
|
||||
public function __construct(private int $threshold)
|
||||
{
|
||||
}
|
||||
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||
{
|
||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
||||
}
|
||||
public function consume(int $tokens = 1): RateLimit
|
||||
{
|
||||
$this->consumed += $tokens;
|
||||
$remaining = max(0, $this->threshold - $this->consumed);
|
||||
return new RateLimit(
|
||||
$remaining,
|
||||
new \DateTimeImmutable('+10 seconds'),
|
||||
$remaining > 0,
|
||||
$this->threshold,
|
||||
);
|
||||
}
|
||||
public function reset(): void
|
||||
{
|
||||
$this->consumed = 0;
|
||||
}
|
||||
};
|
||||
return new class ($limiter) implements RateLimiterFactoryInterface {
|
||||
public function __construct(private LimiterInterface $limiter)
|
||||
{
|
||||
}
|
||||
public function create(?string $key = null): LimiterInterface
|
||||
{
|
||||
return $this->limiter;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Utilities;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Clock\ClockInterface as PsrClockInterface;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
/**
|
||||
* Provides a deterministic TOTP fixture plus a frozen clock and ready-made
|
||||
* ConfigBag / cache-pool helpers for tests that exercise TOTP-dependent code.
|
||||
*/
|
||||
trait TotpTestHelper
|
||||
{
|
||||
/** well-known Base32 test secret (JBSWY3DPEHPK3PXP) */
|
||||
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
/** Frozen timestamp used for deterministic TOTP codes. */
|
||||
protected const string FROZEN_TIME = '2025-06-15 12:00:00';
|
||||
|
||||
/** Frozen clock that always returns the same instant. */
|
||||
private function frozenClock(): PsrClockInterface
|
||||
{
|
||||
$time = self::FROZEN_TIME;
|
||||
return new class ($time) implements PsrClockInterface {
|
||||
public function __construct(private string $time)
|
||||
{
|
||||
}
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable($this->time);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Provisioning URI built from the well-known secret + frozen clock. */
|
||||
private function totpUri(): string
|
||||
{
|
||||
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
|
||||
$totp->setLabel('Test-TOTP');
|
||||
return $totp->getProvisioningUri();
|
||||
}
|
||||
|
||||
/** The TOTP code that is valid at the frozen timestamp. */
|
||||
private function validTotpCode(): string
|
||||
{
|
||||
return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now();
|
||||
}
|
||||
|
||||
/** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */
|
||||
private function emptyPool(): CacheItemPoolInterface
|
||||
{
|
||||
return new ArrayAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ConfigBag wired with the deterministic TOTP and frozen clock.
|
||||
* Extra params override the sensible defaults.
|
||||
*/
|
||||
private function makeConfig(
|
||||
?int $cookieTtl = 3600,
|
||||
?int $ipTtl = 0,
|
||||
bool $teapot = true,
|
||||
string $errorMessage = 'Error',
|
||||
string $teapotTitle = 'Teapot',
|
||||
string $tooManyTitle = 'Too Many',
|
||||
): ConfigBag {
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
return new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
$cookieTtl,
|
||||
$this->totpUri(),
|
||||
$ipTtl,
|
||||
$teapot,
|
||||
$errorMessage,
|
||||
$teapotTitle,
|
||||
$tooManyTitle,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal Utilities stub that never triggers TOTP generation when
|
||||
* a non-empty totpUri is supplied to ConfigBag.
|
||||
*/
|
||||
private function createUtilities(?PsrClockInterface $clock = null): Utilities
|
||||
{
|
||||
$clock ??= $this->frozenClock();
|
||||
$cache = $this->createStub(CacheItemPoolInterface::class);
|
||||
$cache->method('hasItem')->willReturn(false);
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(false);
|
||||
$cache->method('getItem')->willReturn($item);
|
||||
return new Utilities($clock, $cache);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Kernel as AppKernel;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
|
||||
/**
|
||||
* Kernel used by the functional test suite.
|
||||
*
|
||||
* In production the nonce cache is backed by APCu, which naturally persists
|
||||
* across PHP requests. In the test environment the nonce cache is an
|
||||
* in-memory ArrayAdapter; Symfony's ServicesResetter clears it between
|
||||
* requests (even with KernelBrowser::disableReboot()), which would discard
|
||||
* the nonce issued on the login-page request before the login-submission
|
||||
* request can verify it.
|
||||
*
|
||||
* This kernel removes the kernel.reset tag from the nonceCache (and
|
||||
* rateLimitCache) pools so their in-memory state survives across requests
|
||||
* within a single test, mirroring the persistence behaviour of APCu.
|
||||
*/
|
||||
class TestKernel extends AppKernel
|
||||
{
|
||||
protected function build(ContainerBuilder $container): void
|
||||
{
|
||||
parent::build($container);
|
||||
|
||||
$container->addCompilerPass(new class () implements CompilerPassInterface {
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) {
|
||||
if ($container->hasDefinition($poolId)) {
|
||||
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Clock;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ClockTest extends TestCase
|
||||
{
|
||||
public function testNowReturnsDateTimeImmutable(): void
|
||||
{
|
||||
$clock = new Clock();
|
||||
$before = new \DateTimeImmutable();
|
||||
$now = $clock->now();
|
||||
$after = new \DateTimeImmutable();
|
||||
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $now);
|
||||
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
|
||||
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Command;
|
||||
|
||||
use App\Command\GenerateBackupCodesCommand;
|
||||
use App\PersistCache;
|
||||
use App\Service\BackupCodeInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
final class GenerateBackupCodesCommandTest extends TestCase
|
||||
{
|
||||
/** PersistCache is final, so construct a real one backed by ArrayAdapters. */
|
||||
private function makePersistCache(): PersistCache
|
||||
{
|
||||
return new PersistCache(new ArrayAdapter(), new ArrayAdapter());
|
||||
}
|
||||
|
||||
/** A stub BackupCodeInterface that returns the given codes from generate(). */
|
||||
private function makeManagerStub(array $generatedCodes): BackupCodeInterface
|
||||
{
|
||||
$manager = $this->createStub(BackupCodeInterface::class);
|
||||
$manager->method('generate')->willReturn($generatedCodes);
|
||||
return $manager;
|
||||
}
|
||||
|
||||
public function testGenerateDefaultCountOutputsCodes(): void
|
||||
{
|
||||
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
|
||||
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub($codes),
|
||||
$this->makePersistCache()
|
||||
);
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$exit = $tester->execute([]);
|
||||
|
||||
self::assertSame(0, $exit);
|
||||
$output = $tester->getDisplay();
|
||||
foreach ($codes as $code) {
|
||||
self::assertStringContainsString($code, $output);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGenerateSpecificCountPassesCountToManager(): void
|
||||
{
|
||||
$manager = $this->createMock(BackupCodeInterface::class);
|
||||
$manager->expects(self::once())
|
||||
->method('generate')
|
||||
->with(self::identicalTo(5))
|
||||
->willReturn(['c1', 'c2', 'c3', 'c4', 'c5']);
|
||||
|
||||
$command = new GenerateBackupCodesCommand($manager, $this->makePersistCache());
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$exit = $tester->execute(['count' => 5]);
|
||||
|
||||
self::assertSame(0, $exit);
|
||||
}
|
||||
|
||||
public function testDefaultCountArgumentIsTen(): void
|
||||
{
|
||||
// the configured default for the count argument should be 10
|
||||
$manager = $this->createMock(BackupCodeInterface::class);
|
||||
$manager->expects(self::once())
|
||||
->method('generate')
|
||||
->with(self::identicalTo(10))
|
||||
->willReturn(array_fill(0, 10, 'code'));
|
||||
|
||||
$command = new GenerateBackupCodesCommand($manager, $this->makePersistCache());
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
// assertion is in the mock expectation above
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testBootsAndPersistsCache(): void
|
||||
{
|
||||
// PersistCache is final and can't be mocked, but we can verify the
|
||||
// command runs end-to-end with a real instance; boot()/persist()
|
||||
// are invoked implicitly. A successful exit confirms both were called
|
||||
// without throwing.
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub(['code1']),
|
||||
$this->makePersistCache()
|
||||
);
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$exit = $tester->execute([]);
|
||||
|
||||
self::assertSame(0, $exit);
|
||||
}
|
||||
|
||||
public function testZeroCodesOutputsNothing(): void
|
||||
{
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub([]),
|
||||
$this->makePersistCache()
|
||||
);
|
||||
$command->setName('app:generate-backup-codes');
|
||||
|
||||
$tester = new CommandTester($command);
|
||||
$exit = $tester->execute(['count' => 0]);
|
||||
|
||||
self::assertSame(0, $exit);
|
||||
self::assertSame('', trim($tester->getDisplay()));
|
||||
}
|
||||
|
||||
public function testCommandNameAndDescriptionAreConfigured(): void
|
||||
{
|
||||
$command = new GenerateBackupCodesCommand(
|
||||
$this->makeManagerStub([]),
|
||||
$this->makePersistCache()
|
||||
);
|
||||
// configuring via the Application runs the protected configure()
|
||||
$app = new \Symfony\Component\Console\Application();
|
||||
$app->addCommand($command);
|
||||
self::assertSame('app:generate-backup-codes', $command->getName());
|
||||
// the source uses a non-breaking hyphen (U+2011) in "single‑use",
|
||||
// so assert against the substring to avoid encoding fragility
|
||||
self::assertStringContainsString('backup codes', $command->getDescription());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Utilities;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
final class ConfigBagTest extends TestCase
|
||||
{
|
||||
private function createUtilities(?string $totp = null): Utilities
|
||||
{
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$cache = $this->createStub(CacheItemPoolInterface::class);
|
||||
|
||||
if ($totp !== null) {
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(true);
|
||||
$item->method('get')->willReturn($totp);
|
||||
$cache->method('hasItem')->willReturn(true);
|
||||
$cache->method('getItem')->willReturn($item);
|
||||
} else {
|
||||
$cache->method('hasItem')->willReturn(false);
|
||||
}
|
||||
|
||||
return new Utilities($clock, $cache);
|
||||
}
|
||||
|
||||
public function testGettersWithExplicitValues(): void
|
||||
{
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities();
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
3600,
|
||||
'otpauth://totp/test',
|
||||
1800,
|
||||
true,
|
||||
'Error!',
|
||||
'Teapot!',
|
||||
'Too Many!'
|
||||
);
|
||||
|
||||
self::assertSame($clock, $config->clock());
|
||||
self::assertSame(3600, $config->cookieTtl());
|
||||
self::assertSame('otpauth://totp/test', $config->totpUri());
|
||||
self::assertSame(1800, $config->ipTtl());
|
||||
self::assertTrue($config->teapot());
|
||||
self::assertSame('Error!', $config->errorMessage());
|
||||
self::assertSame('Teapot!', $config->teapotTitle());
|
||||
self::assertSame('Too Many!', $config->tooManyTitle());
|
||||
}
|
||||
|
||||
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void
|
||||
{
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities('fallback-totp');
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
3600,
|
||||
'',
|
||||
1800,
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
);
|
||||
|
||||
self::assertSame('fallback-totp', $config->totpUri());
|
||||
}
|
||||
|
||||
public function testIpTtlFallsBackToNullWhenZero(): void
|
||||
{
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities();
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
3600,
|
||||
'otpauth://totp/test',
|
||||
0,
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
}
|
||||
|
||||
public function testIpTtlFallsBackToNullWhenNull(): void
|
||||
{
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities();
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
3600,
|
||||
'otpauth://totp/test',
|
||||
null,
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Data;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\InputBag;
|
||||
|
||||
final class PayloadTest extends TestCase
|
||||
{
|
||||
private static function b64u(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public function testDecodeValidBase64Url(): void
|
||||
{
|
||||
$data = json_encode([
|
||||
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
|
||||
'json' => true, 'scope' => 'cookie',
|
||||
]);
|
||||
$payload = Payload::decode(self::b64u($data));
|
||||
|
||||
self::assertInstanceOf(Payload::class, $payload);
|
||||
self::assertSame('testuser', $payload->id);
|
||||
self::assertSame('123456', $payload->token);
|
||||
self::assertSame('abc123', $payload->nonce);
|
||||
self::assertTrue($payload->json);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testDecodeInvalidBase64UrlReturnsNull(): void
|
||||
{
|
||||
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
|
||||
}
|
||||
|
||||
public function testDecodeNonObjectJsonReturnsNull(): void
|
||||
{
|
||||
self::assertNull(Payload::decode(self::b64u('"just a string"')));
|
||||
}
|
||||
|
||||
public function testDecodeInvalidJsonReturnsNull(): void
|
||||
{
|
||||
// valid base64url but invalid JSON
|
||||
self::assertNull(Payload::decode(self::b64u('{invalid json')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonArrayReturnsNull(): void
|
||||
{
|
||||
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonNullReturnsNull(): void
|
||||
{
|
||||
self::assertNull(Payload::decode(self::b64u('null')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonBooleanReturnsNull(): void
|
||||
{
|
||||
self::assertNull(Payload::decode(self::b64u('true')));
|
||||
self::assertNull(Payload::decode(self::b64u('false')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonNumberReturnsNull(): void
|
||||
{
|
||||
self::assertNull(Payload::decode(self::b64u('42')));
|
||||
}
|
||||
|
||||
public function testDecodeEmptyStringReturnsNull(): void
|
||||
{
|
||||
self::assertNull(Payload::decode(''));
|
||||
}
|
||||
|
||||
public function testLoadWithValidInputBag(): void
|
||||
{
|
||||
$input = new InputBag([
|
||||
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
|
||||
]);
|
||||
$payload = Payload::load($input);
|
||||
|
||||
self::assertInstanceOf(Payload::class, $payload);
|
||||
self::assertSame('alice', $payload->id);
|
||||
self::assertSame('nonce123', $payload->nonce);
|
||||
self::assertSame('654321', $payload->token);
|
||||
self::assertFalse($payload->json);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testLoadMissingUsernameReturnsNull(): void
|
||||
{
|
||||
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testLoadMissingNonceReturnsNull(): void
|
||||
{
|
||||
$input = new InputBag(['username' => 'u', 'totp' => 't']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testLoadMissingTotpReturnsNull(): void
|
||||
{
|
||||
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void
|
||||
{
|
||||
// has() returns true for all, but create() rejects empty values
|
||||
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testCreateWithValidData(): void
|
||||
{
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
'json' => false, 'scope' => 'ip',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
|
||||
self::assertInstanceOf(Payload::class, $payload);
|
||||
self::assertSame('user1', $payload->id);
|
||||
self::assertSame('tok1', $payload->token);
|
||||
self::assertSame('non1', $payload->nonce);
|
||||
self::assertFalse($payload->json);
|
||||
self::assertSame(Scope::Ip, $payload->scope);
|
||||
}
|
||||
|
||||
public function testCreateWithDefaultScope(): void
|
||||
{
|
||||
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||
$payload = Payload::create($data);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testCreateWithInvalidScopeFallsBackToCookie(): void
|
||||
{
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
'scope' => 'admin',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testCreateWithMissingJsonDefaultsToTrue(): void
|
||||
{
|
||||
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||
$payload = Payload::create($data);
|
||||
self::assertTrue($payload->json);
|
||||
}
|
||||
|
||||
public function testCreateWithNoneScopeSetsJsonFalse(): void
|
||||
{
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
'json' => true, 'scope' => 'none',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
self::assertSame(Scope::None, $payload->scope);
|
||||
self::assertFalse($payload->json);
|
||||
}
|
||||
|
||||
public function testCreateWithEmptyIdReturnsNull(): void
|
||||
{
|
||||
$data = (object)['id' => '', 'token' => 't', 'nonce' => 'n'];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateWithWhitespaceIdReturnsNull(): void
|
||||
{
|
||||
$data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n'];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateWithEmptyTokenReturnsNull(): void
|
||||
{
|
||||
$data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n'];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateWithEmptyNonceReturnsNull(): void
|
||||
{
|
||||
$data = (object)['id' => 'u', 'token' => 't', 'nonce' => ''];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateTrimsAndTruncatesFields(): void
|
||||
{
|
||||
$long = str_repeat('a', 200);
|
||||
$data = (object)[
|
||||
'id' => ' ' . $long . ' ',
|
||||
'token' => ' ' . $long . ' ',
|
||||
'nonce' => ' ' . $long . ' ',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
$expected = mb_substr($long, 0, 128);
|
||||
self::assertSame($expected, $payload->id);
|
||||
self::assertSame($expected, $payload->token);
|
||||
self::assertSame($expected, $payload->nonce);
|
||||
}
|
||||
|
||||
public function testToString(): void
|
||||
{
|
||||
$payload = new Payload();
|
||||
$payload->id = 'u';
|
||||
$payload->token = 't';
|
||||
$payload->nonce = 'n';
|
||||
$payload->json = true;
|
||||
$payload->scope = Scope::Cookie;
|
||||
|
||||
$decoded = json_decode($payload->toString(), true);
|
||||
self::assertSame('u', $decoded['id']);
|
||||
self::assertSame('t', $decoded['token']);
|
||||
self::assertSame('n', $decoded['nonce']);
|
||||
self::assertTrue($decoded['json']);
|
||||
self::assertSame('cookie', $decoded['scope']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Enum;
|
||||
|
||||
use App\Enum\Scope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScopeTest extends TestCase
|
||||
{
|
||||
public function testCases(): void
|
||||
{
|
||||
self::assertSame('cookie', Scope::Cookie->value);
|
||||
self::assertSame('ip', Scope::Ip->value);
|
||||
self::assertSame('none', Scope::None->value);
|
||||
}
|
||||
|
||||
public function testTryFromValid(): void
|
||||
{
|
||||
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
|
||||
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
|
||||
self::assertSame(Scope::None, Scope::tryFrom('none'));
|
||||
}
|
||||
|
||||
public function testTryFromInvalid(): void
|
||||
{
|
||||
self::assertNull(Scope::tryFrom('invalid'));
|
||||
self::assertNull(Scope::tryFrom(''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\AcceptListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
final class AcceptListenerTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
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);
|
||||
$listener->setLogger(new NullLogger());
|
||||
return $listener;
|
||||
}
|
||||
|
||||
private function makeEvent(Request $request): RequestEvent
|
||||
{
|
||||
return new RequestEvent(
|
||||
$this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class),
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── valid cookie session ─────────────────────────────────────────── */
|
||||
|
||||
public function testValidCookieSetsResponseWithRemoteUser(): 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);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$ulid = '01HXY1234567890ABCDEFGHIJK';
|
||||
$item = $pool->getItem('cookie_' . $ulid);
|
||||
$item->set('bob');
|
||||
$pool->save($item);
|
||||
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener($pool, $domainManager);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::AUTH_COOKIE_NAME, $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame('bob', $event->getResponse()->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
/* ── negative cases ───────────────────────────────────────────────── */
|
||||
|
||||
public function testNoCookieSetsNoResponse(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($pool, $domainManager);
|
||||
|
||||
$event = $this->makeEvent(Request::create('/', 'GET'));
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
public function testCookieWithoutSessionSetsNoResponse(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($pool, $domainManager);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, 'unknown-ulid');
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
public function testEmptyCookieValueSetsNoResponse(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($pool, $domainManager);
|
||||
|
||||
// cookies->set with empty string
|
||||
$request = Request::create('/', 'GET');
|
||||
$request->cookies->set(self::COOKIE_NAME, '');
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
// empty cookie value should not be treated as a valid session
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Listener\AllowListener;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
final class AllowListenerTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
private function makeListener(ArrayAdapter $pool, ConfigBag $config): AllowListener
|
||||
{
|
||||
$listener = new AllowListener($pool, $config);
|
||||
$listener->setLogger(new NullLogger());
|
||||
return $listener;
|
||||
}
|
||||
|
||||
private function makeEvent(Request $request): RequestEvent
|
||||
{
|
||||
return new RequestEvent(
|
||||
$this->createStub(HttpKernelInterface::class),
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
public function testValidIpSessionSetsResponseWithRemoteUser(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$item = $pool->getItem('ip_1.2.3.4');
|
||||
$item->set('carol');
|
||||
$pool->save($item);
|
||||
|
||||
$config = $this->makeConfig(ipTtl: 1800);
|
||||
$listener = $this->makeListener($pool, $config);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('carol', $response->headers->get('Remote-User'));
|
||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function testNoIpSessionSetsNoResponse(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$config = $this->makeConfig(ipTtl: 1800);
|
||||
$listener = $this->makeListener($pool, $config);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '9.9.9.9']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
public function testIpAccessDisabledSetsNoResponse(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
// even though there's a stored session, ip access is disabled
|
||||
$item = $pool->getItem('ip_1.2.3.4');
|
||||
$item->set('carol');
|
||||
$pool->save($item);
|
||||
|
||||
$config = $this->makeConfig(ipTtl: 0);
|
||||
$listener = $this->makeListener($pool, $config);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
public function testIpAccessDisabledDoesNotCheckCache(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$config = $this->makeConfig(ipTtl: 0);
|
||||
$listener = $this->makeListener($pool, $config);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
// when disabled, nothing should have been written/read as a session
|
||||
self::assertFalse($event->hasResponse());
|
||||
self::assertFalse($pool->hasItem('ip_1.2.3.4'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\InterceptListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Tests\Support\ListenerTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
final class InterceptListenerTest extends TestCase
|
||||
{
|
||||
use ListenerTestHelper;
|
||||
|
||||
private const string COOKIE_NAME = '__Host-Http-Preauth';
|
||||
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
|
||||
|
||||
private function makeListener(
|
||||
DomainManager $domainManager,
|
||||
?CacheItemPoolInterface $nonceCache = null,
|
||||
): InterceptListener {
|
||||
$listener = new InterceptListener(
|
||||
$this->makeConfig(),
|
||||
$domainManager,
|
||||
$this->makeTwig(),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache($nonceCache ?? new ArrayAdapter());
|
||||
return $listener;
|
||||
}
|
||||
|
||||
private function makeEvent(Request $request): RequestEvent
|
||||
{
|
||||
return new RequestEvent(
|
||||
$this->createStub(HttpKernelInterface::class),
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── central-auth redirect branch ─────────────────────────────────── */
|
||||
|
||||
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void
|
||||
{
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
$request = Request::create('https://app.example.com/dashboard', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode());
|
||||
$location = $response->headers->get('Location');
|
||||
self::assertStringStartsWith('https://auth.example.com/?', $location);
|
||||
// the return query should contain the original url
|
||||
self::assertStringContainsString('return=', $location);
|
||||
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
|
||||
}
|
||||
|
||||
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void
|
||||
{
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
// should render login page, not redirect
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertNotSame(Response::HTTP_SEE_OTHER, $response->getStatusCode());
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── login page rendering branch ──────────────────────────────────── */
|
||||
|
||||
public function testPresentsLoginPageWithUnauthorizedStatus(): void
|
||||
{
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||
$content = $response->getContent();
|
||||
self::assertStringContainsString('<form', $content);
|
||||
// the rendered page should embed a freshly generated nonce
|
||||
self::assertStringContainsString('name="nonce"', $content);
|
||||
}
|
||||
|
||||
public function testGeneratedNonceIsStoredInCache(): void
|
||||
{
|
||||
$nonceCache = new ArrayAdapter();
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($domainManager, $nonceCache);
|
||||
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
// exactly one nonce should now exist in the cache, marked valid
|
||||
$found = false;
|
||||
foreach ($nonceCache->getValues() as $key => $value) {
|
||||
if (str_starts_with($key, 'test_') || preg_match('/^[A-Za-z0-9_.]+$/', $key)) {
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
// ArrayAdapter stores raw values; verify at least one item was saved
|
||||
self::assertTrue(count($nonceCache->getValues()) > 0);
|
||||
}
|
||||
|
||||
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void
|
||||
{
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$content = $event->getResponse()->getContent();
|
||||
// when on the auth subdomain, post=true so the form has method="post"
|
||||
self::assertStringContainsString('method="post"', $content);
|
||||
}
|
||||
|
||||
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void
|
||||
{
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$content = $event->getResponse()->getContent();
|
||||
// not on auth subdomain, so the form should NOT have method="post"
|
||||
self::assertStringNotContainsString('method="post"', $content);
|
||||
}
|
||||
|
||||
/* ── invalid cookie pruning ───────────────────────────────────────── */
|
||||
|
||||
public function testInvalidCookieIsClearedWhenPresent(): void
|
||||
{
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
// send a cookie that won't match any session (so AcceptListener didn't fire)
|
||||
$request->cookies->set(self::COOKIE_NAME, 'stale-ulid');
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
// a Clear-Site-Data style clearCookie should produce a Set-Cookie that expires it
|
||||
$cookies = $response->headers->getCookies();
|
||||
$cleared = false;
|
||||
foreach ($cookies as $cookie) {
|
||||
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
|
||||
$cleared = true;
|
||||
}
|
||||
}
|
||||
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
|
||||
}
|
||||
|
||||
public function testNoCookieClearingWhenNoCookiePresent(): void
|
||||
{
|
||||
$domainManager = new DomainManager(false, '');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertSame([], $response->headers->getCookies());
|
||||
}
|
||||
|
||||
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void
|
||||
{
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener($domainManager);
|
||||
|
||||
// request to auth subdomain with a stale auth-domain cookie
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
$request->cookies->set(self::AUTH_COOKIE_NAME, 'stale-ulid');
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
$cleared = false;
|
||||
foreach ($response->headers->getCookies() as $cookie) {
|
||||
if ($cookie->getName() === self::AUTH_COOKIE_NAME && $cookie->isCleared()) {
|
||||
$cleared = true;
|
||||
}
|
||||
}
|
||||
self::assertTrue($cleared, 'Expected the auth cookie to be cleared');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\Listener\LoginListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Service\LoginInterface;
|
||||
use App\Tests\Support\ListenerTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
final class LoginListenerTest extends TestCase
|
||||
{
|
||||
use ListenerTestHelper;
|
||||
|
||||
private const string HEADER_NAME = 'X-Preauth';
|
||||
|
||||
private function makeListener(
|
||||
?LoginInterface $loginManager = null,
|
||||
?DomainManager $domainManager = null,
|
||||
?int $rateLimitRemaining = 5,
|
||||
): LoginListener {
|
||||
$listener = new LoginListener(
|
||||
$this->makeTwig(),
|
||||
$this->makeRateLimiterFactory($rateLimitRemaining ?? 5),
|
||||
$domainManager ?? new DomainManager(false, ''),
|
||||
$loginManager ?? $this->createStub(LoginInterface::class),
|
||||
$this->makeConfig(),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache(new ArrayAdapter());
|
||||
return $listener;
|
||||
}
|
||||
|
||||
private function makeEvent(Request $request): RequestEvent
|
||||
{
|
||||
return new RequestEvent(
|
||||
$this->createStub(HttpKernelInterface::class),
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a base64url-encoded X-Preauth header value for a payload. */
|
||||
private function encodePayload(array $data): string
|
||||
{
|
||||
$json = json_encode($data, JSON_THROW_ON_ERROR);
|
||||
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/* ── no login attempt ─────────────────────────────────────────────── */
|
||||
|
||||
public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void
|
||||
{
|
||||
$listener = $this->makeListener();
|
||||
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void
|
||||
{
|
||||
// POST only counts as a login attempt when on the auth subdomain
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener(domainManager: $domainManager);
|
||||
|
||||
$request = Request::create('https://app.example.com/', 'POST');
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
/* ── successful login via header ──────────────────────────────────── */
|
||||
|
||||
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void
|
||||
{
|
||||
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
|
||||
$loginManager = $this->createStub(LoginInterface::class);
|
||||
$loginManager->method('checkToken')->willReturn($expected);
|
||||
|
||||
$listener = $this->makeListener(loginManager: $loginManager);
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
'id' => 'alice', 'token' => '123456', 'nonce' => 'nonce-1', 'json' => true,
|
||||
]);
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$request->headers->set(self::HEADER_NAME, $payload);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame($expected, $event->getResponse());
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginViaPostToAuthSubdomain(): void
|
||||
{
|
||||
$expected = new Response('hi bob', 303, ['Location' => '/']);
|
||||
$loginManager = $this->createStub(LoginInterface::class);
|
||||
$loginManager->method('checkToken')->willReturn($expected);
|
||||
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener(loginManager: $loginManager, domainManager: $domainManager);
|
||||
|
||||
$request = Request::create('https://auth.example.com/', 'POST', [
|
||||
'username' => 'bob', 'totp' => '654321', 'nonce' => 'nonce-2',
|
||||
]);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame($expected, $event->getResponse());
|
||||
}
|
||||
|
||||
/* ── failed login ─────────────────────────────────────────────────── */
|
||||
|
||||
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void
|
||||
{
|
||||
$loginManager = $this->createStub(LoginInterface::class);
|
||||
$loginManager->method('checkToken')->willReturn(null);
|
||||
|
||||
$listener = $this->makeListener(loginManager: $loginManager);
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true,
|
||||
]);
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$request->headers->set(self::HEADER_NAME, $payload);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
$body = json_decode($response->getContent(), true);
|
||||
// the TotpTestHelper::makeConfig default errorMessage is 'Error'
|
||||
self::assertSame('Error', $body['message']);
|
||||
self::assertNotEmpty($body['nonce']);
|
||||
self::assertFalse($body['post']);
|
||||
// username is echoed back (sanitized via makeCacheKey)
|
||||
self::assertSame('alice', $body['username']);
|
||||
}
|
||||
|
||||
public function testFailedLoginHtmlResponseWhenJsonFalse(): void
|
||||
{
|
||||
$loginManager = $this->createStub(LoginInterface::class);
|
||||
$loginManager->method('checkToken')->willReturn(null);
|
||||
|
||||
$listener = $this->makeListener(loginManager: $loginManager);
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false,
|
||||
]);
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$request->headers->set(self::HEADER_NAME, $payload);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('<form', $response->getContent());
|
||||
}
|
||||
|
||||
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void
|
||||
{
|
||||
$loginManager = $this->createStub(LoginInterface::class);
|
||||
$loginManager->method('checkToken')->willReturn(null);
|
||||
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener(
|
||||
loginManager: $loginManager,
|
||||
domainManager: $domainManager,
|
||||
);
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false,
|
||||
]);
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
$request->headers->set(self::HEADER_NAME, $payload);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$content = $event->getResponse()->getContent();
|
||||
self::assertStringContainsString('method="post"', $content);
|
||||
}
|
||||
|
||||
/* ── rate-limited (blocked) login ─────────────────────────────────── */
|
||||
|
||||
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void
|
||||
{
|
||||
$loginManager = $this->createStub(LoginInterface::class);
|
||||
$loginManager->method('checkToken')->willReturn(null);
|
||||
|
||||
// limiter with 0 remaining tokens -> blocked
|
||||
$listener = $this->makeListener(
|
||||
loginManager: $loginManager,
|
||||
rateLimitRemaining: 0,
|
||||
);
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true,
|
||||
]);
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$request->headers->set(self::HEADER_NAME, $payload);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode());
|
||||
$body = json_decode($response->getContent(), true);
|
||||
// the TotpTestHelper::makeConfig default teapotTitle is 'Teapot'
|
||||
self::assertSame('Teapot', $body['message']);
|
||||
}
|
||||
|
||||
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void
|
||||
{
|
||||
$loginManager = $this->createStub(LoginInterface::class);
|
||||
$loginManager->method('checkToken')->willReturn(null);
|
||||
|
||||
$listener = new LoginListener(
|
||||
$this->makeTwig(),
|
||||
$this->makeRateLimiterFactory(0),
|
||||
new DomainManager(false, ''),
|
||||
$loginManager,
|
||||
$this->makeConfig(teapot: false),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache(new ArrayAdapter());
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true,
|
||||
]);
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$request->headers->set(self::HEADER_NAME, $payload);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
|
||||
$body = json_decode($response->getContent(), true);
|
||||
// teapot disabled, so tooManyTitle is used; helper default is 'Too Many'
|
||||
self::assertSame('Too Many', $body['message']);
|
||||
}
|
||||
|
||||
/* ── invalid payload handling ─────────────────────────────────────── */
|
||||
|
||||
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void
|
||||
{
|
||||
$loginManager = $this->createMock(LoginInterface::class);
|
||||
// checkToken should not be called with a null payload
|
||||
$loginManager->expects(self::never())->method('checkToken');
|
||||
|
||||
$listener = $this->makeListener(loginManager: $loginManager);
|
||||
|
||||
// an un-decodable header value
|
||||
$request = Request::create('https://example.com/', 'GET');
|
||||
$request->headers->set(self::HEADER_NAME, '!!!not-valid-base64!!!');
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
// Payload::decode returns null, so checkToken is skipped, but a
|
||||
// failure response is still produced (the rate limiter is consulted)
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void
|
||||
{
|
||||
$loginManager = $this->createMock(LoginInterface::class);
|
||||
$loginManager->expects(self::never())->method('checkToken');
|
||||
|
||||
$domainManager = new DomainManager(true, 'auth.example.com');
|
||||
$listener = $this->makeListener(
|
||||
loginManager: $loginManager,
|
||||
domainManager: $domainManager,
|
||||
);
|
||||
|
||||
// POST to auth subdomain but missing the required fields
|
||||
$request = Request::create('https://auth.example.com/', 'POST', ['username' => 'only-user']);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
// Payload::load returns null (missing totp & nonce), so it falls through
|
||||
// to the failure path and produces a response
|
||||
self::assertTrue($event->hasResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\RejectListener;
|
||||
use App\Service\DomainManager;
|
||||
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;
|
||||
|
||||
final class RejectListenerTest extends TestCase
|
||||
{
|
||||
use ListenerTestHelper;
|
||||
|
||||
private function makeListener(
|
||||
bool $teapot = true,
|
||||
int $remainingTokens = 5,
|
||||
): RejectListener {
|
||||
$listener = new RejectListener(
|
||||
$this->makeConfig(teapot: $teapot),
|
||||
$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,
|
||||
);
|
||||
}
|
||||
|
||||
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void
|
||||
{
|
||||
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
||||
|
||||
$request = Request::create('/', '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_I_AM_A_TEAPOT, $response->getStatusCode());
|
||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void
|
||||
{
|
||||
$listener = $this->makeListener(teapot: false, remainingTokens: 0);
|
||||
|
||||
$request = Request::create('/', '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'));
|
||||
}
|
||||
|
||||
public function testUnblockedRequestSetsNoResponse(): void
|
||||
{
|
||||
$listener = $this->makeListener(remainingTokens: 5);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
// consume(0) with remaining tokens > 0 should not block
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
public function testBlockedResponseContainsErrorTemplateContent(): void
|
||||
{
|
||||
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$content = $event->getResponse()->getContent();
|
||||
// Twig escapes the apostrophe in "I'm a teapot" to '
|
||||
self::assertStringContainsString('a teapot', $content);
|
||||
self::assertStringContainsString('I refuse to brew coffee', $content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\MonitorCacheKeys;
|
||||
use OutOfBoundsException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
final class MonitorCacheKeysTest extends TestCase
|
||||
{
|
||||
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
|
||||
{
|
||||
$pool ??= new ArrayAdapter();
|
||||
return new MonitorCacheKeys($pool);
|
||||
}
|
||||
|
||||
public function testConstructorInitializesEmptyPool(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
self::assertSame([], $monitor->getChanges());
|
||||
}
|
||||
|
||||
public function testSaveAddsKeyAndTracksChange(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('alpha');
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertSame(['alpha'], $monitor->getKeys());
|
||||
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||
}
|
||||
|
||||
public function testSaveDeferredThenCommitAddsKey(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('beta');
|
||||
$item->set('value');
|
||||
$monitor->saveDeferred($item);
|
||||
|
||||
// saveDeferred calls update() which commits immediately
|
||||
self::assertSame(['beta'], $monitor->getKeys());
|
||||
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||
}
|
||||
|
||||
public function testGetItemReturnsUnderlyingItem(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('mykey');
|
||||
$item->set('data');
|
||||
$monitor->save($item);
|
||||
|
||||
$fetched = $monitor->getItem('mykey');
|
||||
self::assertTrue($fetched->isHit());
|
||||
self::assertSame('data', $fetched->get());
|
||||
}
|
||||
|
||||
public function testGetItemsReturnsMultipleItems(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$a = $monitor->getItem('a');
|
||||
$a->set(1);
|
||||
$monitor->save($a);
|
||||
$b = $monitor->getItem('b');
|
||||
$b->set(2);
|
||||
$monitor->save($b);
|
||||
|
||||
$items = $monitor->getItems(['a', 'b']);
|
||||
$keys = [];
|
||||
foreach ($items as $key => $item) {
|
||||
$keys[$key] = $item->get();
|
||||
}
|
||||
self::assertSame(['a' => 1, 'b' => 2], $keys);
|
||||
}
|
||||
|
||||
public function testHasItemReturnsTrueForExistingKey(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('exists');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertTrue($monitor->hasItem('exists'));
|
||||
self::assertFalse($monitor->hasItem('missing'));
|
||||
}
|
||||
|
||||
public function testDeleteItemRemovesKeyAndTracksRemoval(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('doomed');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
$monitor->deleteItem('doomed');
|
||||
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
self::assertSame(['doomed' => MonitorCacheKeys::REMOVED], $monitor->getChanges());
|
||||
self::assertFalse($monitor->hasItem('doomed'));
|
||||
}
|
||||
|
||||
public function testDeleteItemOnMissingKeyIsNoop(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$result = $monitor->deleteItem('nonexistent');
|
||||
|
||||
self::assertTrue($result);
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testDeleteItemsRemovesMultipleKeys(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
foreach (['x', 'y', 'z'] as $key) {
|
||||
$item = $monitor->getItem($key);
|
||||
$item->set($key);
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
$monitor->deleteItems(['x', 'y']);
|
||||
|
||||
self::assertSame(['z'], $monitor->getKeys());
|
||||
$changes = $monitor->getChanges();
|
||||
self::assertSame(MonitorCacheKeys::REMOVED, $changes['x']);
|
||||
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
|
||||
}
|
||||
|
||||
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$result = $monitor->deleteItems(['ghost1', 'ghost2']);
|
||||
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testClearWipesPoolWhenNotEmpty(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('keep');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
$result = $monitor->clear();
|
||||
|
||||
self::assertTrue($result);
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testClearIsNoopWhenEmpty(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$result = $monitor->clear();
|
||||
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testMarkCleanResetsChangeList(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('temp');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertNotEmpty($monitor->getChanges());
|
||||
|
||||
$monitor->markClean();
|
||||
|
||||
self::assertSame([], $monitor->getChanges());
|
||||
self::assertSame(['temp'], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testCommitPassesThrough(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
self::assertTrue($monitor->commit());
|
||||
}
|
||||
|
||||
public function testSaveKeyListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('__key_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
public function testSaveChangeListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('__chg_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
public function testDeleteKeyListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItem('__key_list');
|
||||
}
|
||||
|
||||
public function testDeleteChangeListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItem('__chg_list');
|
||||
}
|
||||
|
||||
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItems(['safe', '__key_list']);
|
||||
}
|
||||
|
||||
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItems(['__chg_list']);
|
||||
}
|
||||
|
||||
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('safe');
|
||||
$item->set('value');
|
||||
|
||||
// getItem returns the real item, but saveDeferred calls update() which
|
||||
// validates the key — so we need to get the __key_list item and try to save it
|
||||
$keyListItem = $monitor->getItem('__key_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->saveDeferred($keyListItem);
|
||||
}
|
||||
|
||||
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$changeListItem = $monitor->getItem('__chg_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->saveDeferred($changeListItem);
|
||||
}
|
||||
|
||||
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void
|
||||
{
|
||||
// If the underlying pool loses its key list, getKeys should return []
|
||||
$pool = new ArrayAdapter();
|
||||
$monitor = new MonitorCacheKeys($pool);
|
||||
|
||||
$item = $monitor->getItem('alpha');
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
|
||||
// delete the key list directly from the underlying pool
|
||||
$pool->deleteItem('__key_list');
|
||||
|
||||
$monitor2 = new MonitorCacheKeys($pool);
|
||||
// the constructor will re-initialize since __key_list is missing
|
||||
// but getKeys on the new monitor should be empty
|
||||
self::assertSame([], $monitor2->getKeys());
|
||||
}
|
||||
|
||||
public function testDeleteItemReturnsTrueForExistingKey(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('to-delete');
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertTrue($monitor->deleteItem('to-delete'));
|
||||
self::assertNotContains('to-delete', $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testDeleteItemsReturnsTrue(): void
|
||||
{
|
||||
$monitor = $this->wrap();
|
||||
foreach (['a', 'b', 'c'] as $key) {
|
||||
$item = $monitor->getItem($key);
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
self::assertTrue($monitor->deleteItems(['a', 'b', 'c']));
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\MonitorCacheKeys;
|
||||
use App\PersistCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
final class PersistCacheTest extends TestCase
|
||||
{
|
||||
public function testBootWithEmptyStorageIsNoop(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// nothing was loaded since storage is empty
|
||||
$monitor = new MonitorCacheKeys($sessionCache);
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testBootLoadsFromStorageIntoCache(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// populate storage with some session data
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item = $storageMonitor->getItem('cookie_abc');
|
||||
$item->set('user1');
|
||||
$storageMonitor->save($item);
|
||||
$storageMonitor->markClean();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// session cache should now contain the loaded data
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
self::assertContains('cookie_abc', $cacheMonitor->getKeys());
|
||||
self::assertSame('user1', $cacheMonitor->getItem('cookie_abc')->get());
|
||||
// boot should mark clean so no changes are pending
|
||||
self::assertSame([], $cacheMonitor->getChanges());
|
||||
}
|
||||
|
||||
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// warm up the cache with existing data
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_existing');
|
||||
$item->set('old-user');
|
||||
$cacheMonitor->save($item);
|
||||
|
||||
// put different data in storage
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item = $storageMonitor->getItem('cookie_new');
|
||||
$item->set('new-user');
|
||||
$storageMonitor->save($item);
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// existing data should be preserved, storage data NOT loaded
|
||||
$monitor = new MonitorCacheKeys($sessionCache);
|
||||
self::assertContains('cookie_existing', $monitor->getKeys());
|
||||
self::assertNotContains('cookie_new', $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testPersistWritesChangesToStorage(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// write something to the session cache
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_xyz');
|
||||
$item->set('user2');
|
||||
$cacheMonitor->save($item);
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// storage should now contain the change
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertContains('cookie_xyz', $storageMonitor->getKeys());
|
||||
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
|
||||
}
|
||||
|
||||
public function testPersistHandlesRemovals(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// seed storage with an item
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item = $storageMonitor->getItem('cookie_to_remove');
|
||||
$item->set('user3');
|
||||
$storageMonitor->save($item);
|
||||
$storageMonitor->markClean();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// now delete it from session cache
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$cacheMonitor->deleteItem('cookie_to_remove');
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// storage should no longer have it
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
|
||||
}
|
||||
|
||||
public function testPersistIsNoopWhenNoChanges(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
$persist->persist();
|
||||
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertSame([], $storageMonitor->getKeys());
|
||||
}
|
||||
|
||||
public function testFullBootModifyPersistCycle(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// boot (empty), add data, persist
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_cycle');
|
||||
$item->set('cycled-user');
|
||||
$cacheMonitor->save($item);
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// simulate a new request: fresh cache, same storage
|
||||
$newCache = new ArrayAdapter();
|
||||
$persist2 = new PersistCache($newCache, $sessionStorage);
|
||||
$persist2->boot();
|
||||
|
||||
$monitor = new MonitorCacheKeys($newCache);
|
||||
self::assertContains('cookie_cycle', $monitor->getKeys());
|
||||
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
|
||||
}
|
||||
|
||||
public function testPersistHandlesMixedUpdatesAndRemovals(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// seed storage with two items
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item1 = $storageMonitor->getItem('cookie_keep');
|
||||
$item1->set('user-keep');
|
||||
$storageMonitor->save($item1);
|
||||
$item2 = $storageMonitor->getItem('cookie_remove');
|
||||
$item2->set('user-remove');
|
||||
$storageMonitor->save($item2);
|
||||
$storageMonitor->markClean();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// update one item and delete the other in the same cycle
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item1 = $cacheMonitor->getItem('cookie_keep');
|
||||
$item1->set('user-updated');
|
||||
$cacheMonitor->save($item1);
|
||||
$cacheMonitor->deleteItem('cookie_remove');
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// storage should reflect both changes
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertContains('cookie_keep', $storageMonitor->getKeys());
|
||||
self::assertSame('user-updated', $storageMonitor->getItem('cookie_keep')->get());
|
||||
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
|
||||
}
|
||||
|
||||
public function testMultipleBootModifyPersistCycles(): void
|
||||
{
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// cycle 1: add item A
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_a');
|
||||
$item->set('user-a');
|
||||
$cacheMonitor->save($item);
|
||||
$persist->persist();
|
||||
|
||||
// cycle 2: fresh cache, add item B, keep A from storage
|
||||
$newCache = new ArrayAdapter();
|
||||
$persist2 = new PersistCache($newCache, $sessionStorage);
|
||||
$persist2->boot();
|
||||
$cacheMonitor2 = new MonitorCacheKeys($newCache);
|
||||
$item = $cacheMonitor2->getItem('cookie_b');
|
||||
$item->set('user-b');
|
||||
$cacheMonitor2->save($item);
|
||||
$persist2->persist();
|
||||
|
||||
// cycle 3: fresh cache, both A and B should be loaded from storage
|
||||
$newCache2 = new ArrayAdapter();
|
||||
$persist3 = new PersistCache($newCache2, $sessionStorage);
|
||||
$persist3->boot();
|
||||
$monitor = new MonitorCacheKeys($newCache2);
|
||||
self::assertContains('cookie_a', $monitor->getKeys());
|
||||
self::assertSame('user-a', $monitor->getItem('cookie_a')->get());
|
||||
self::assertContains('cookie_b', $monitor->getKeys());
|
||||
self::assertSame('user-b', $monitor->getItem('cookie_b')->get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\BackupCodeManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
final class BackupCodeManagerTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
private function makeManager(?ArrayAdapter $pool = null): BackupCodeManager
|
||||
{
|
||||
$pool ??= new ArrayAdapter();
|
||||
$manager = new BackupCodeManager($pool);
|
||||
$manager->setConfig($this->makeConfig());
|
||||
$manager->setLogger(new NullLogger());
|
||||
return $manager;
|
||||
}
|
||||
|
||||
public function testGenerateReturnsRequestedCount(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(5);
|
||||
|
||||
self::assertCount(5, $codes);
|
||||
foreach ($codes as $code) {
|
||||
self::assertIsString($code);
|
||||
// codes are lowercase alphanumeric
|
||||
self::assertMatchesRegularExpression('/^[a-z0-9]+$/', $code);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGenerateDefaultCount(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate();
|
||||
|
||||
self::assertCount(10, $codes);
|
||||
}
|
||||
|
||||
public function testGenerateZeroReturnsEmptyArray(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(0);
|
||||
|
||||
self::assertSame([], $codes);
|
||||
}
|
||||
|
||||
public function testGeneratedCodesAreStoredInCache(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
$codes = $manager->generate(3);
|
||||
|
||||
// each code should be stored as a backup_ key
|
||||
foreach ($codes as $code) {
|
||||
$key = 'backup_' . strtolower($code);
|
||||
// the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity
|
||||
$item = $pool->getItem($key);
|
||||
self::assertTrue($item->isHit(), "Expected cache hit for key: $key");
|
||||
self::assertTrue($item->get(), "Expected code to be marked valid (true)");
|
||||
}
|
||||
}
|
||||
|
||||
public function testGeneratedCodesHaveFarFutureExpiry(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
$item = $pool->getItem('backup_' . strtolower($code));
|
||||
$expiry = $item->getMetadata()['expiry'];
|
||||
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeValidCode(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
$codes = $manager->generate(2);
|
||||
|
||||
$code = $codes[0];
|
||||
|
||||
self::assertTrue($manager->verifyAndConsume($code));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeMarksCodeAsUsed(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// first use succeeds
|
||||
self::assertTrue($manager->verifyAndConsume($code));
|
||||
|
||||
// second use fails (already consumed)
|
||||
self::assertFalse($manager->verifyAndConsume($code));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeInvalidCode(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeIsCaseInsensitive(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// uppercase version should still work
|
||||
self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeStripsInvalidCharacters(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// inject spaces and special chars — should be stripped
|
||||
self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!'));
|
||||
}
|
||||
|
||||
public function testExpireRemovesAllBackupCodes(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
$codes = $manager->generate(5);
|
||||
|
||||
$manager->expire();
|
||||
|
||||
// all backup keys should be gone
|
||||
foreach ($codes as $code) {
|
||||
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
|
||||
}
|
||||
}
|
||||
|
||||
public function testExpireWhenNoBackupCodesIsNoop(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
// should not throw
|
||||
$manager->expire();
|
||||
|
||||
// this passes if no exception was thrown
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testExpireRemovesOnlyBackupPrefixedKeys(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
$codes = $manager->generate(3);
|
||||
|
||||
// add a non-backup key
|
||||
$item = $pool->getItem('cookie_session');
|
||||
$item->set('data');
|
||||
$pool->save($item);
|
||||
|
||||
$manager->expire();
|
||||
|
||||
// non-backup key survives
|
||||
self::assertTrue($pool->hasItem('cookie_session'));
|
||||
|
||||
// backup keys are gone
|
||||
foreach ($codes as $code) {
|
||||
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
|
||||
}
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
// empty string after preg_replace becomes 'backup_' with nothing after it
|
||||
self::assertFalse($manager->verifyAndConsume(''));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// first use succeeds
|
||||
self::assertTrue($manager->verifyAndConsume($code));
|
||||
|
||||
// the code is now marked as false (used); isHit is true but get() is false
|
||||
$key = 'backup_' . strtolower($code);
|
||||
$item = $pool->getItem($key);
|
||||
self::assertTrue($item->isHit());
|
||||
self::assertFalse($item->get());
|
||||
|
||||
// second use should fail because get() returns false
|
||||
self::assertFalse($manager->verifyAndConsume($code));
|
||||
}
|
||||
|
||||
public function testGenerateProducesUniqueCodes(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(50);
|
||||
|
||||
self::assertCount(50, $codes);
|
||||
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
|
||||
}
|
||||
|
||||
public function testGenerateCodeLengthIsDigitsPlusTwo(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(1);
|
||||
|
||||
// default TOTP digits is 6, so code length should be 6 + 2 = 8
|
||||
self::assertSame(8, strlen($codes[0]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\DomainManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DomainManagerTest extends TestCase
|
||||
{
|
||||
private function createManager(bool $subdomainRedirect, string $authSubdomain): DomainManager
|
||||
{
|
||||
return new DomainManager($subdomainRedirect, $authSubdomain);
|
||||
}
|
||||
|
||||
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
|
||||
|
||||
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void
|
||||
{
|
||||
$manager = $this->createManager(false, 'auth.example.com');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void
|
||||
{
|
||||
$manager = $this->createManager(true, '');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseExtractsSimpleDomain(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertSame('example.com', $manager->authBase());
|
||||
self::assertSame('auth.example.com', $manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseExtractsMultiPartTld(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||
self::assertSame('example.co.uk', $manager->authBase());
|
||||
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseIsNullForLocalhostAuth(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'localhost');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseIsNullForIpAuth(): void
|
||||
{
|
||||
$manager = $this->createManager(true, '192.168.1.1');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
/* ── validReturn ──────────────────────────────────────────────────────── */
|
||||
|
||||
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void
|
||||
{
|
||||
$manager = $this->createManager(false, '');
|
||||
self::assertTrue($manager->validReturn('https://evil.com/page'));
|
||||
self::assertTrue($manager->validReturn('https://example.com/ok'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsInvalidUrl(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('not-a-url'));
|
||||
self::assertFalse($manager->validReturn(''));
|
||||
}
|
||||
|
||||
public function testValidReturnAcceptsSameBaseDomain(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
|
||||
self::assertTrue($manager->validReturn('https://example.com/'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsDifferentBaseDomain(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('https://evil.com/phish'));
|
||||
self::assertFalse($manager->validReturn('https://other-example.com/'));
|
||||
}
|
||||
|
||||
public function testValidReturnHandlesCoUkTld(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||
self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
|
||||
self::assertFalse($manager->validReturn('https://example.com/'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsUrlWithoutHost(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('mailto:test@example.com'));
|
||||
}
|
||||
|
||||
/* ── matchesAuth ──────────────────────────────────────────────────────── */
|
||||
|
||||
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void
|
||||
{
|
||||
$manager = $this->createManager(false, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
self::assertFalse($manager->matchesAuth('app.example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void
|
||||
{
|
||||
$manager = $this->createManager(true, '');
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthMatchesSameBaseDomain(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->matchesAuth('example.com'));
|
||||
self::assertTrue($manager->matchesAuth('app.example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthRejectsDifferentBaseDomain(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('evil.com'));
|
||||
self::assertFalse($manager->matchesAuth('example.org'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthHandlesMultiPartTld(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||
self::assertTrue($manager->matchesAuth('www.example.co.uk'));
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthRejectsIpHost(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('192.168.1.1'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthRejectsLocalhost(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('localhost'));
|
||||
}
|
||||
|
||||
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
|
||||
|
||||
public function testMatchesAuthWithDeepSubdomain(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithTwoPartDomain(): void
|
||||
{
|
||||
/* for a 2-part auth subdomain, the baseDomain retains both parts */
|
||||
$manager = $this->createManager(true, 'auth.local');
|
||||
self::assertSame('auth.local', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('auth.local'));
|
||||
self::assertFalse($manager->matchesAuth('local'));
|
||||
self::assertFalse($manager->matchesAuth('app.local'));
|
||||
}
|
||||
|
||||
/* ── TLD table coverage ──────────────────────────────────────────────── */
|
||||
|
||||
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
|
||||
$manager = $this->createManager(true, 'auth.example.com.au');
|
||||
self::assertSame('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
|
||||
$manager = $this->createManager(true, 'auth.example.co.jp');
|
||||
self::assertSame('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
|
||||
$manager = $this->createManager(true, 'auth.example.com.br');
|
||||
self::assertSame('com.br', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.com.br'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithCoNzTld(): void
|
||||
{
|
||||
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
|
||||
$manager = $this->createManager(true, 'auth.example.co.nz');
|
||||
self::assertSame('example.co.nz', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithComMxTld(): void
|
||||
{
|
||||
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
|
||||
$manager = $this->createManager(true, 'auth.example.com.mx');
|
||||
self::assertSame('example.com.mx', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithCoInTld(): void
|
||||
{
|
||||
// co.in: in => [co,...], so *.co.in IS multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.co.in');
|
||||
self::assertSame('example.co.in', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.co.in'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithBrComTld(): void
|
||||
{
|
||||
// br.com: TLD table has com => [br], so *.br.com IS multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.br.com');
|
||||
self::assertSame('example.br.com', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.br.com'));
|
||||
}
|
||||
|
||||
public function testSimpleTldNotTreatedAsMultiPart(): void
|
||||
{
|
||||
// example.com is a standard 2-part domain, not multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertSame('example.com', $manager->authBase());
|
||||
// auth.example.org should NOT match example.com
|
||||
self::assertFalse($manager->matchesAuth('app.example.org'));
|
||||
}
|
||||
|
||||
/* ── baseDomain edge cases ───────────────────────────────────────────── */
|
||||
|
||||
public function testMatchesAuthWithSingleLabelHost(): void
|
||||
{
|
||||
// a single-label domain (not localhost, not IP) has baseLength 1
|
||||
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
|
||||
// they won't match unless the auth subdomain itself is single-label
|
||||
$manager = $this->createManager(true, 'auth.local');
|
||||
// auth.local base is 'auth.local', 'local' base is 'local' -> no match
|
||||
self::assertFalse($manager->matchesAuth('local'));
|
||||
// but a subdomain of auth.local does match
|
||||
self::assertTrue($manager->matchesAuth('app.auth.local'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithEmptyStringHost(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth(''));
|
||||
}
|
||||
|
||||
public function testValidReturnAcceptsUrlWithPort(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
|
||||
}
|
||||
|
||||
public function testValidReturnAcceptsUrlWithoutPath(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->validReturn('https://example.com'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsDifferentDomainWithPort(): void
|
||||
{
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\Service\BackupCodeInterface;
|
||||
use App\Service\DomainManager;
|
||||
use App\Trait\StringTrait;
|
||||
use App\Service\LoginManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class LoginManagerTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
use StringTrait;
|
||||
|
||||
private ArrayAdapter $pool;
|
||||
private BackupCodeInterface $backupCodeManager;
|
||||
private DomainManager $domainManager;
|
||||
|
||||
private function makeLoginManager(
|
||||
?int $ipTtl = 0,
|
||||
bool $subdomainRedirect = false,
|
||||
string $authSubdomain = '',
|
||||
): LoginManager {
|
||||
$this->pool = new ArrayAdapter();
|
||||
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
||||
$this->domainManager = new DomainManager($subdomainRedirect, $authSubdomain);
|
||||
|
||||
$manager = new LoginManager($this->pool, $this->backupCodeManager, $this->domainManager);
|
||||
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new ArrayAdapter());
|
||||
return $manager;
|
||||
}
|
||||
|
||||
/** Build a Payload with a valid server-side nonce already stored. */
|
||||
private function makePayloadWithNonce(
|
||||
LoginManager $manager,
|
||||
string $id = 'testuser',
|
||||
Scope $scope = Scope::Cookie,
|
||||
?string $token = null,
|
||||
): Payload {
|
||||
$token ??= $this->validTotpCode();
|
||||
$nonce = $this->insertNonce($manager, 'test-nonce-123');
|
||||
|
||||
$payload = new Payload();
|
||||
$payload->id = $id;
|
||||
$payload->token = $token;
|
||||
$payload->nonce = $nonce;
|
||||
$payload->json = true;
|
||||
$payload->scope = $scope;
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** Inject a nonce directly into the manager's nonce cache. */
|
||||
private function insertNonce(LoginManager $manager, string $nonce): string
|
||||
{
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||
$nonceCache = $reflection->getValue($manager);
|
||||
|
||||
$key = $this->makeCacheKey($nonce);
|
||||
$item = $nonceCache->getItem($key);
|
||||
$item->set(true);
|
||||
$nonceCache->save($item);
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
public function testCheckTokenReturnsNullForInvalidTotp(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
self::assertNull($manager->checkToken($payload, $request));
|
||||
}
|
||||
|
||||
public function testCheckTokenReturnsNullForSpentNonce(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// spend the nonce first (use the same cache key the manager does)
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||
$nonceCache = $reflection->getValue($manager);
|
||||
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
||||
$nonceItem->set(false);
|
||||
$nonceCache->save($nonceItem);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
self::assertNull($manager->checkToken($payload, $request));
|
||||
}
|
||||
|
||||
public function testCheckTokenReturnsNullForMissingNonce(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$payload = new Payload();
|
||||
$payload->id = 'user1';
|
||||
$payload->token = $this->validTotpCode();
|
||||
$payload->nonce = 'never-stored';
|
||||
$payload->json = true;
|
||||
$payload->scope = Scope::Cookie;
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
self::assertNull($manager->checkToken($payload, $request));
|
||||
}
|
||||
|
||||
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/dashboard', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(303, $response->getStatusCode()); // HTTP_SEE_OTHER
|
||||
self::assertTrue($response->headers->has('Location'));
|
||||
self::assertTrue($response->headers->has('Set-Cookie'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||
self::assertTrue($response->headers->has('Remote-User'));
|
||||
// no redirect for Scope::None
|
||||
self::assertFalse($response->headers->has('Location'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginSetsRemoteUserHeader(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginJsonResponse(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
|
||||
$payload->json = true;
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/protected', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
$body = json_decode($response->getContent(), true);
|
||||
self::assertSame('Login successful', $body['message']);
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginHtmlResponse(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
$payload->json = false;
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/protected', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithReturnUrl(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/login?return=https://example.com/app', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('https://example.com/app', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/login?return=not-a-url', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$location = $response->headers->get('Location');
|
||||
self::assertStringStartsWith('/login', $location);
|
||||
}
|
||||
|
||||
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager(ipTtl: 0);
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
// Should have a Set-Cookie (downgraded to cookie scope)
|
||||
self::assertNotNull($response);
|
||||
self::assertTrue($response->headers->has('Set-Cookie'));
|
||||
}
|
||||
|
||||
public function testIpScopeWhenEnabledSetsIpSession(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager(ipTtl: 1800);
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
// IP session should be stored; no Set-Cookie for IP scope
|
||||
self::assertFalse($response->headers->has('Set-Cookie'));
|
||||
|
||||
// verify the IP session exists in the cache
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'sessionCache');
|
||||
$sessionCache = $reflection->getValue($manager);
|
||||
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
|
||||
}
|
||||
|
||||
public function testBackupCodeAuthentication(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(true);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(303, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testNonceIsConsumedAfterSuccessfulLogin(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$manager->checkToken($payload, $request);
|
||||
|
||||
// nonce should now be marked invalid (false); look it up via the same
|
||||
// cache key the manager uses (makeCacheKey rewrites '-' to '_')
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||
$nonceCache = $reflection->getValue($manager);
|
||||
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
||||
self::assertFalse($nonceItem->get());
|
||||
}
|
||||
|
||||
public function testUlidCollisionThrowsHttpException(): void
|
||||
{
|
||||
// Use a stub pool where every cookie_ key is already a hit (collision)
|
||||
$pool = $this->createStub(CacheItemPoolInterface::class);
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(true);
|
||||
$item->method('get')->willReturn('existing');
|
||||
// The nonce cache needs to work, so we return the stub item for
|
||||
// cookie_ keys but a real working item for nonce keys.
|
||||
$pool->method('getItem')->willReturnCallback(function (string $key) use ($item) {
|
||||
if (str_starts_with($key, 'cookie_')) {
|
||||
return $item; // collision
|
||||
}
|
||||
// For nonce keys, return a real item from an ArrayAdapter
|
||||
static $realPool = null;
|
||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
return $realPool->getItem($key);
|
||||
});
|
||||
$pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) {
|
||||
if (str_starts_with($key, 'cookie_')) {
|
||||
return true;
|
||||
}
|
||||
static $realPool = null;
|
||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
return $realPool->hasItem($key);
|
||||
});
|
||||
$pool->method('save')->willReturn(true);
|
||||
$pool->method('saveDeferred')->willReturn(true);
|
||||
$pool->method('commit')->willReturn(true);
|
||||
$pool->method('getItems')->willReturnCallback(function (array $keys) {
|
||||
static $realPool = null;
|
||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
return $realPool->getItems($keys);
|
||||
});
|
||||
$pool->method('clear')->willReturn(true);
|
||||
$pool->method('deleteItem')->willReturn(true);
|
||||
$pool->method('deleteItems')->willReturn(true);
|
||||
|
||||
$this->domainManager = new DomainManager(false, '');
|
||||
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
|
||||
$manager->setConfig($this->makeConfig());
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter());
|
||||
|
||||
$payload = new Payload();
|
||||
$payload->id = 'collide-user';
|
||||
$payload->token = $this->validTotpCode();
|
||||
$payload->nonce = 'test-nonce-123';
|
||||
$payload->json = true;
|
||||
$payload->scope = Scope::Cookie;
|
||||
|
||||
// inject the nonce
|
||||
$this->insertNonce($manager, 'test-nonce-123');
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$this->expectException(HttpException::class);
|
||||
$manager->checkToken($payload, $request);
|
||||
}
|
||||
|
||||
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager(
|
||||
subdomainRedirect: true,
|
||||
authSubdomain: 'auth.example.com',
|
||||
);
|
||||
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// host matches the auth base domain
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$cookies = $response->headers->getCookies();
|
||||
self::assertCount(1, $cookies);
|
||||
// when using central auth and host matches, the cookie domain is set
|
||||
self::assertSame('example.com', $cookies[0]->getDomain());
|
||||
// the auth cookie name is used instead of the host-prefixed name
|
||||
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
||||
}
|
||||
|
||||
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager(
|
||||
subdomainRedirect: true,
|
||||
authSubdomain: 'auth.example.com',
|
||||
);
|
||||
$payload = $this->makePayloadWithNonce($manager, id: 'bob', scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// host does NOT match the auth base domain
|
||||
$request = Request::create('https://other.com/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$cookies = $response->headers->getCookies();
|
||||
self::assertCount(1, $cookies);
|
||||
// domain is null when host does not match
|
||||
self::assertNull($cookies[0]->getDomain());
|
||||
// still uses auth cookie name since authBase is set
|
||||
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
||||
}
|
||||
|
||||
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void
|
||||
{
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// return parameter is present but empty string
|
||||
$request = Request::create('/?return=', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$location = $response->headers->get('Location');
|
||||
self::assertNotNull($location);
|
||||
// should fall back to path since empty string is not a valid URL
|
||||
self::assertStringStartsWith('/', $location);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Trait\CookieNameTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CookieNameTraitTest extends TestCase
|
||||
{
|
||||
use CookieNameTrait;
|
||||
|
||||
public function testCookieName(): void
|
||||
{
|
||||
self::assertSame('__Host-Http-Preauth', $this->cookieName());
|
||||
}
|
||||
|
||||
public function testAuthCookieName(): void
|
||||
{
|
||||
self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
|
||||
}
|
||||
|
||||
public function testHeaderName(): void
|
||||
{
|
||||
self::assertSame('X-Preauth', $this->headerName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use OTPHP\TOTPInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class GetTotpTraitTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
private function makeObject(): object
|
||||
{
|
||||
return new class () {
|
||||
use GetTotpTrait;
|
||||
|
||||
public function publicGetTotp(): TOTPInterface
|
||||
{
|
||||
return $this->getTotp();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testSetConfigSetsProperty(): void
|
||||
{
|
||||
$obj = $this->makeObject();
|
||||
$config = $this->makeConfig();
|
||||
|
||||
$obj->setConfig($config);
|
||||
|
||||
$reflection = new \ReflectionProperty($obj, 'config');
|
||||
self::assertSame($config, $reflection->getValue($obj));
|
||||
}
|
||||
|
||||
public function testGetTotpReturnsTotpInterface(): void
|
||||
{
|
||||
$obj = $this->makeObject();
|
||||
$obj->setConfig($this->makeConfig());
|
||||
|
||||
$totp = $obj->publicGetTotp();
|
||||
|
||||
self::assertInstanceOf(TOTPInterface::class, $totp);
|
||||
}
|
||||
|
||||
public function testGetTotpReturnsValidCode(): void
|
||||
{
|
||||
$obj = $this->makeObject();
|
||||
$obj->setConfig($this->makeConfig());
|
||||
|
||||
$totp = $obj->publicGetTotp();
|
||||
|
||||
// the code at the frozen time should match our helper
|
||||
self::assertSame($this->validTotpCode(), $totp->now());
|
||||
}
|
||||
|
||||
public function testGetTotpThrowsOnInvalidUri(): void
|
||||
{
|
||||
$obj = $this->makeObject();
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
$config = new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
3600,
|
||||
'not-a-valid-uri',
|
||||
0,
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
// Factory::loadFromProvisioningUri throws InvalidProvisioningUriException
|
||||
// which is not caught by getTotp() since the instanceof check only runs
|
||||
// after a successful load — so we expect a Throwable here
|
||||
$this->expectException(\Throwable::class);
|
||||
$obj->publicGetTotp();
|
||||
}
|
||||
|
||||
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void
|
||||
{
|
||||
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
|
||||
// so the instanceof check in getTotp() should throw an HttpException(500)
|
||||
$obj = $this->makeObject();
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
$config = new ConfigBag(
|
||||
$utilities,
|
||||
$clock,
|
||||
3600,
|
||||
'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0',
|
||||
0,
|
||||
false,
|
||||
'Error',
|
||||
'Teapot',
|
||||
'Too Many'
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
$this->expectException(HttpException::class);
|
||||
$this->expectExceptionMessage('Internal Server Exception');
|
||||
$obj->publicGetTotp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class HasLoggerTraitTest extends TestCase
|
||||
{
|
||||
use HasLoggerTrait;
|
||||
|
||||
public function testSetLogger(): void
|
||||
{
|
||||
$logger = $this->createStub(LoggerInterface::class);
|
||||
$this->setLogger($logger);
|
||||
self::assertSame($logger, $this->logger);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user