18 Commits
Author SHA1 Message Date
andrew d6bcbf661e Merge pull request 'Add PHPUnit test suite — 222 tests, 100% code coverage' (#1) from Lyra into main
Push Develop / docker (push) Successful in 7m5s
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 46s
Push Docker / docker (push) Successful in 4m44s
First AI contribution to main branch, a full suite of tests, thank you GLM-5.2.
2026-08-07 09:13:16 -04:00
lyra de2a382cbf Add Gitea workflow to run test suite on push and PR
Sync GitHub / sync (push) Successful in 7s
Tests / test (pull_request) Successful in 1m58s
2026-08-07 09:06:32 -04:00
lyra 12ba6cde7b Add unit tests for untested edge cases and code paths
Sync GitHub / sync (push) Successful in 9s
PayloadTest:
- decode() with invalid JSON, JSON array, null, boolean, number
- load() with all fields present but empty strings
- create() with invalid scope string (falls back to cookie)
- create() with missing json property (defaults to true)

DomainManagerTest:
- TLD table coverage: com.au, co.jp, com.br, co.nz, com.mx, co.in, br.com
- Single-label host matching behavior
- Empty string host
- validReturn with URL containing port, without path, different domain with port

MakeNonceTraitTest:
- Retry-then-succeed path (first attempt collides, second succeeds)
- Zero retries throws immediately on collision

StringTraitTest:
- Only invalid characters collapses to single underscore
- Exactly 128 characters boundary
- Multibyte characters (é, ö) replaced with underscore
- Emoji replaced with underscore

GetTotpTraitTest:
- HOTP URI triggers HttpException(500) via instanceof check

LoginManagerTest:
- ULID collision throws HttpException(500)
- Central auth cookie sets domain on matching host (uses auth cookie name)
- Central auth cookie uses null domain on non-matching host
- Empty return parameter falls back to request path

BackupCodeManagerTest:
- Empty string code returns false
- Code with value false (consumed) verified via cache state
- Generated codes are unique (50 codes)
- Code length equals TOTP digits + 2

MonitorCacheKeysTest:
- saveDeferred on __key_list and __chg_list throws OutOfBoundsException
- getKeys returns empty when key list is lost
- deleteItem/deleteItems return true assertions

PersistCacheTest:
- Mixed updates and removals in same persist cycle
- Multiple boot-modify-persist cycles with interleaved modifications
2026-08-06 06:10:31 -04:00
lyra 6c5a7c98e8 Fix failing functional tests and add test infrastructure
- Add TestKernel that removes the kernel.reset tag from nonceCache,
  rateLimitCache, sessionCache and sessionStorage pools so in-memory
  state survives across requests within a single test (mirroring APCu
  persistence in production)
- Add config/packages/test/ with array cache adapters and test session
  config
- Set fixed TOTP secret (JBSWY3DPEHPK3PXP) and high rate limits in
  phpunit.dist.xml and .env.test so functional tests can compute valid
  codes and are not rate-limited
- Make Kernel non-final so TestKernel can extend it
- Fix testFailedLoginWithSpentNonceIsRejected and
  testConsumedBackupCodeCannotBeReused: clear the CookieJar between
  sub-requests so a session cookie set by a prior successful login does
  not auto-authenticate the next request via AcceptListener before the
  nonce/backup-code path is exercised
2026-08-06 06:07:14 -04:00
lyra 4d314bcb28 Add unit tests for all listeners, the backup-codes command, and test support helpers
- Add ListenerTestHelper providing a real Twig Environment pointed at the
  project templates and configurable RateLimiterFactoryInterface doubles
  (fixed-remaining and counting variants)
- Add AcceptListenerTest: valid cookie session (host & auth-subdomain cookie
  names), missing/empty cookie, no session
- Add AllowListenerTest: valid IP session, missing session, IP access disabled
- Add RejectListenerTest: blocked -> teapot / too-many-requests, unblocked,
  error template content
- Add InterceptListenerTest: central-auth redirect, login page rendering,
  post form on auth subdomain, invalid-cookie pruning (host & auth cookies)
- Add LoginListenerTest: no-login early return, successful login via header
  and via POST to auth subdomain, failed login (json/html), rate-limited
  (teapot/too-many), invalid payload handling
- Add GenerateBackupCodesCommandTest: default/specific count, default argument
  is 10, boot+persist invocation, zero codes, name & description configuration
- Fix MakeNonceTraitTest nonce cache lookups to use makeCacheKey() (was flaky
  when random nonce contained '-')
2026-08-05 16:55:28 -04:00
lyra 890cc225ef Add PHPUnit test suite and supporting infrastructure
- Add phpunit/phpunit ^13.2, symfony/browser-kit and symfony/css-selector
  to require-dev, plus the autoload-dev mapping for App\Tests- Add phpunit.dist.xml (strict deprecation/notice/warning failures,
  APP_ENV=test forced) and .env.test / bin/phpunit / tests/bootstrap.php
  from the PHPUnit recipe
- Add tests/Support/TotpTestHelper providing a deterministic TOTP
  fixture, frozen clock and ConfigBag/cache-pool helpers
- Add 121 unit tests covering Clock, ConfigBag, Data/Payload, Enum/Scope,
  MonitorCacheKeys, PersistCache, Utilities, all five Traits and the
  three Service managers (BackupCode, Domain, Login)
- Fix LoginManagerTest nonce lookups to use makeCacheKey() so the cache
  key matches the one the manager actually reads/writes
- Gitignore bin/.phpunit.result.cache
2026-08-05 16:52:44 -04:00
andrew 3c1253ee45 Merge branch 'develop'
Push Docker / docker (push) Successful in 4m47s
Push Develop / docker (push) Successful in 4m44s
Sync GitHub / sync (push) Successful in 6s
2026-06-17 13:27:48 -04:00
andrew 70bf811b1d gitea action cleanup
Push Develop / docker (push) Successful in 6m49s
Sync GitHub / sync (push) Successful in 7s
2026-06-17 10:55:26 -04:00
andrew 3269151e9b new workflow to build and push to docker hub
Sync GitHub / sync (push) Failing after 6s
2026-06-14 18:27:32 -04:00
andrew 1da2188bdf Gitea action to sync to GitHub
Sync GitHub / sync (push) Successful in 7s
2026-06-13 21:28:59 -04:00
andrew 7cf7e04d17 extracted interfaces for services, to aid in creating tests 2026-06-05 15:15:21 -04:00
andrew 0813323ac2 renamed form fields to work better with password managers; fixed bug where an invalid login requests were not being counted as login attempts; preserve username when using central auth 2026-06-01 16:28:57 -04:00
andrew 9114cfd96f update to php 8.5, backup codes, etc.
modified:   Dockerfile
	modified:   composer.json
	modified:   composer.lock
	modified:   config/packages/twig.yaml
	modified:   config/services.yaml
	modified:   docs/Caddyfile
	modified:   docs/compose.yaml
	renamed:    docs/env.example -> docs/example.env
	modified:   public/index.php
	modified:   readme.md
	modified:   src/Command/GenerateBackupCodesCommand.php
	modified:   src/ConfigBag.php
	modified:   src/Data/Payload.php
	modified:   src/Enum/Scope.php
	modified:   src/Listener/AcceptListener.php
	modified:   src/Listener/AllowListener.php
	modified:   src/Listener/InterceptListener.php
	modified:   src/Listener/LoginListener.php
	modified:   src/MonitorCacheKeys.php
	modified:   src/PersistCache.php
	modified:   src/Service/BackupCodeManager.php
	modified:   src/Service/DomainManager.php
	new file:   src/Service/LoginManager.php
	modified:   src/Trait/CookieNameTrait.php
	modified:   src/Trait/GetTotpTrait.php
	modified:   src/Trait/MakeNonceTrait.php
	modified:   src/Trait/StringTrait.php
	modified:   src/Utilities.php
	modified:   templates/_script.html.twig
	modified:   templates/_style.html.twig
	modified:   templates/base.html.twig
	modified:   templates/login.html.twig
2026-05-29 21:56:42 -04:00
andrew 43e9b7136e auth subdomain tentatively complete.
All domain logic moved into service.
2026-05-22 12:43:02 -04:00
andrew 38124ef66c First draft of backup codes. only created when the command is called. no command yet to expire/review codes.
Also added a few safeguards against excessively long user input.

Started on ability to redirect to auth subdomain (incomplete).
2026-05-21 12:15:35 -04:00
andrew 6ed1ab26f1 cache persistence improvement, only update keys which have changed. More efficient, and less likely to cause race conditions. 2026-03-12 17:15:49 -04:00
andrew a0dc1a6049 rate-limiting update, now using a compound sliding-window. Continuing to move over to using traits more, and other code cleanup. 2026-03-11 08:27:27 -04:00
andrew 3d28485921 Removal of static-secret and totp-lookup.
Intending to build support for single-use backup codes.

Started refactoring to move trait dependencies internally, so that classes only have to specify their own direct dependencies.
2026-03-09 16:22:32 -04:00
89 changed files with 8111 additions and 1467 deletions
+27
View File
@@ -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
+34
View File
@@ -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
+34
View File
@@ -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 }}
+37
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
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 tests
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
+7 -1
View File
@@ -1,4 +1,4 @@
/.idea/
###> symfony/framework-bundle ###
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
@@ -6,3 +6,9 @@
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
/bin/.phpunit.result.cache
###< phpunit/phpunit ###
+4 -4
View File
@@ -1,5 +1,5 @@
# use build image, to simplify final image
FROM php:8.4-trixie AS build
FROM php:8.5-trixie AS build
# install APCu and composer
RUN pecl install apcu && \
@@ -12,7 +12,6 @@ RUN apt-get update && \
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
ENV DEFAULT_URI='http://'
# load application into build image
RUN mkdir -p /data/preauth
@@ -32,7 +31,7 @@ RUN composer install --no-dev --optimize-autoloader
RUN composer dump-env prod --empty
# start creating final image
FROM dunglas/frankenphp:php8.4-trixie
FROM dunglas/frankenphp:php8.5-trixie
# install APCu
RUN pecl install apcu && \
@@ -42,7 +41,6 @@ RUN pecl install apcu && \
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
ENV DEFAULT_URI='http://'
# load application into final image
WORKDIR /app
@@ -53,6 +51,8 @@ COPY --from=build /app /app
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
# app uses var folder for cache storage
VOLUME ["/config", "/data"]
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
+12 -6
View File
@@ -1,20 +1,21 @@
{
"type": "project",
"license": "proprietary",
"license": "MIT",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"bacon/bacon-qr-code": "^3.0",
"runtime/frankenphp-symfony": "^0.2.0",
"spomky-labs/otphp": "^11.3",
"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",
"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.*",
@@ -72,5 +73,10 @@
"allow-contrib": false,
"require": "7.4.*"
}
},
"require-dev": {
"phpunit/phpunit": "^13.2",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*"
}
}
Generated
+2619 -270
View File
File diff suppressed because it is too large Load Diff
+9 -33
View File
@@ -2,38 +2,14 @@ framework:
cache:
app: cache.adapter.filesystem
pools:
noncePool:
adapters:
- cache.adapter.apcu
sessionPool:
adapters:
- cache.adapter.apcu
requestPool:
adapters:
- cache.adapter.apcu
persistSessionPool:
adapters:
- cache.adapter.filesystem
persistRequestPool:
adapters:
- cache.adapter.filesystem
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.
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: digitaladapt/preauth
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads
# as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null
-9
View File
@@ -7,12 +7,3 @@ framework:
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
+15
View File
@@ -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]
+1 -5
View File
@@ -1,10 +1,6 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts,
# such as CLI commands. See
# https://symfony.com/doc/current/routing.html
# #generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
default_uri: 'http://localhost'
when@prod:
framework:
+12
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
+1 -6
View File
@@ -3,21 +3,16 @@ twig:
strict_variables: true
globals:
env:
allow_password: '%env(STATIC_SECRET_ENABLED)%'
title: '%env(TITLE)%'
bg_color: '%env(BG_COLOR)%'
fg_color: '%env(FG_COLOR)%'
error_color: '%env(ERROR_COLOR)%'
return_field: '%env(QUERY_PREFIX)%return'
id_field: '%env(QUERY_PREFIX)%id'
token_field: '%env(QUERY_PREFIX)%token'
password_field: '%env(QUERY_PREFIX)%password'
id_name: '%env(ID_NAME)%'
token_name: '%env(TOKEN_NAME)%'
password_name: '%env(PASSWORD_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)%'
+404 -407
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers
-4
View File
@@ -1,4 +0,0 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error
+25 -28
View File
@@ -8,42 +8,40 @@
# https://symfony.com/doc/current/best_practices.html
# #use-parameters-for-application-configuration
parameters:
# --- main variables ---
# --- 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
# rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
# number of consecutive failed login attempts before we block the ip address
env(LIMIT): '4' # default 4 failed login attempts before blocking
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
env(LIMIT_TIMEOUT): '21600' # default 6 hours
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
env(LIMIT_TTL): '86400' # default 24 hours
# 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 variables ---
# query parameter prefix to prevent collisions
env(QUERY_PREFIX): '_preauth_'
# --- 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
# if desired, in addition to supporting a TOTP, you can set a static password
env(STATIC_SECRET_ENABLED): '0' # boolean
env(STATIC_SECRET): '' # default disabled
env(LOOKUP_TOTP): '' # default disabled
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
# --- styling variables ---
# --- 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'
env(FG_COLOR): '#ffffff'
env(ERROR_COLOR): '#ffb16d'
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(PASSWORD_NAME): 'Authentication Password'
env(SUBMIT_NAME): 'Submit'
env(ERROR_MESSAGE): 'Unsuccessful login attempt'
# title and message to use on block page, if teapot is true
@@ -53,17 +51,16 @@ parameters:
env(TOO_MANY_TITLE): 'Too many requests'
env(TOO_MANY_MESSAGE): 'Try again later'
app.cookie_ttl: '%env(COOKIE_TTL)%'
app.limit: '%env(LIMIT)%'
app.limit_timeout: '%env(LIMIT_TIMEOUT)%'
app.limit_ttl: '%env(LIMIT_TTL)%'
app.query_prefix: '%env(QUERY_PREFIX)%'
# --- 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.static_secret_enabled: '%env(STATIC_SECRET_ENABLED)%'
app.static_secret: '%env(STATIC_SECRET)%'
app.lookup_totp: '%env(LOOKUP_TOTP)%'
app.teapot: '%env(TEAPOT)%'
app.error_message: '%env(ERROR_MESSAGE)%'
+20 -25
View File
@@ -1,33 +1,28 @@
# if using caddy v2.9.x+ you can use this snippet
# snippet to put the preauth system in front any service easily
(preauth) {
# make sure caddy and preauth are on the same network
reverse_proxy {args[0]} preauth {
# leave body content for protected service
method GET
# if auth is successful, send request to protected service
@preauth_ok status 2xx
handle_response @preauth_ok {
{block}
}
}
}
# example of securing full subdomain
# TODO replace domain and service name
# example of securing full service
# TODO replace domain and service name and port
service.example.com {
import preauth * {
reverse_proxy service_container
forward_auth preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy service-container:80
}
# you can only lock down only select paths
# or any other match criteria, if desired
# https://protected.example.com/secure/
# 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 {
import preauth /secure/* {
reverse_proxy protected-service:9000
# note any request that does not start with "/secure/" is NOT protected
forward_auth /secure/* preauth {
uri {uri}
copy_headers Remote-User
}
reverse_proxy exposed-service:9000
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
}
+2 -5
View File
@@ -1,7 +1,7 @@
services:
preauth:
env_file:
# TODO rename "env.example" to ".env", edit as needed
# 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
@@ -10,10 +10,7 @@ services:
image: digitaladapt/preauth:latest
restart: unless-stopped
# if you wish to set the user, you must make sure that the user
# can write to /app/var/ within the container, and that all files
# and folders within are writable as well
# IE: `$chown -R <uid>:<gid> /path/to/volume/of/app/var`
#
# can write to /config and /data within the container
#user: <uid>:<gid>
volumes:
- preauth-config:/config
+22 -23
View File
@@ -1,4 +1,4 @@
# --- Main Options ---
# --- 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
@@ -8,43 +8,39 @@
# how long will someone stay logged in, measured in seconds, zero for DEFAULT
#COOKIE_TTL=2592000 # default 30 days
# NOTE: rate limiting can *NOT* be disabled,
# but you could allow hundreds of logins a second
# 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
# number of consecutive failed login attempts before we block the ip address
#LIMIT=4 # default 4 failed login attempts before blocking
# time between failed login attempts that are consecutive, in seconds, zero for DEFAULT
#LIMIT_TIMEOUT=21600 # default 6 hours
# how long a blocked ip address stay blocks, in seconds, zero for DEFAULT
#LIMIT_TTL=86400 # default 24 hours
# --- Extra Options ---
# --- 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
# if desired, in addition to supporting a TOTP, you can set a static password
# if you enable a static secret, you may choose to also enable a totp-lookup,
# which contains a special path, when successfully authenticated,
# instead of returning "hi <your-name>" it returns "code <totp-code>".
#STATIC_SECRET_ENABLED='0' # boolean, disabled by default
#STATIC_SECRET='' # default disabled
#LOOKUP_TOTP='' # default disabled
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
# --- Styling Options ---
# --- 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'
PASSWORD_NAME='Authentication Password'
#SUBMIT_NAME='Submit'
#ERROR_MESSAGE='Unsuccessful login attempt'
# title and message to use on block page, if teapot is true
@@ -54,3 +50,6 @@ PASSWORD_NAME='Authentication Password'
#TOO_MANY_TITLE='Too many requests'
#TOO_MANY_MESSAGE='Try again later'
# --- debug options ---
#SHELL_VERBOSITY=0 # set to "3" to log debug
+49
View File
@@ -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
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
use App\Kernel;
+21 -2
View File
@@ -19,13 +19,32 @@ There is an example Caddyfile in /docs/ and env.example file to get you started.
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.
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 shown the protected service. It is also possible to allow all requests from an approved IP address, but that is disabled by default.
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.
Be sure to save that TOTP secret to your docker environment, so that it persistents beyond removing the container.
Be sure to save that TOTP secret to your docker environment, so that it persists beyond removing the container.
## Backup Codes
It is possible to generate single-use backup codes via a console command within the docker container.
```shell
docker exec -t preauth bin/console app:generate-backup-codes [count=10]
```
### History
#### v0.7.0 (May 29th, 2026)
Added ability to generate single-use backup codes.
Removed static password and lookup token, as they were security risks.
Updated to PHP 8.5, updated dependencies.
#### v0.6.0 (Feb 10th, 2026)
Added optional (disabled by default) ability to lookup token by static password.
#### v0.5.0 (Jan 17th, 2026)
Nonce related cleanup; added optional (disabled by default) ability to use a static password as a backup means of authentication.
#### v0.4.1 (Dec 26th, 2025)
Fixed bug which can occur if you delete cache files.
@@ -0,0 +1,42 @@
<?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 singleuse 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;
}
}
+15 -58
View File
@@ -10,14 +10,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ConfigBag {
private ClockInterface $clock;
private int $cookieTtl;
private int $limit;
private int $limitTimeout;
private int $limitTtl;
private string $queryPrefix;
private string $totpUri;
private ?int $ipTtl;
private ?string $lookupTotp;
private ?string $staticSecret;
private bool $teapot;
private string $errorMessage;
private string $teapotTitle;
@@ -25,36 +19,23 @@ final readonly class ConfigBag {
/** @throws InvalidArgumentException */
public function __construct(
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.limit%')] int $limit,
#[Autowire('%app.limit_timeout%')] int $limitTimeout,
#[Autowire('%app.limit_ttl%')] int $limitTtl,
#[Autowire('%app.query_prefix%')] string $queryPrefix,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.lookup_totp%')] ?string $lookupTotp,
#[Autowire('%app.static_secret_enabled%')] bool $staticSecretEnabled,
#[Autowire('%app.static_secret%')] ?string $staticSecret,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
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->limit = ($limit >= 1) ? $limit : 4;
$this->limitTimeout = ($limitTimeout >= 1) ? $limitTimeout : 21600;
$this->limitTtl = ($limitTtl >= 1) ? $limitTtl : 86400;
$this->queryPrefix = $queryPrefix;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->staticSecret = $staticSecretEnabled ? ($staticSecret ?: null) : null;
$this->lookupTotp = $this->staticSecret ? ($lookupTotp ?: null) : null;
$this->teapot = $teapot;
$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->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle;
}
@@ -66,22 +47,6 @@ final readonly class ConfigBag {
return $this->cookieTtl;
}
public function limit(): int {
return $this->limit;
}
public function limitTimeout(): int {
return $this->limitTimeout;
}
public function limitTtl(): int {
return $this->limitTtl;
}
public function query(string $field): string {
return "$this->queryPrefix$field";
}
public function totpUri(): string {
return $this->totpUri;
}
@@ -90,14 +55,6 @@ final readonly class ConfigBag {
return $this->ipTtl;
}
public function lookupTotp(): ?string {
return $this->lookupTotp;
}
public function staticSecret(): ?string {
return $this->staticSecret;
}
public function teapot(): bool {
return $this->teapot;
}
+32 -34
View File
@@ -4,16 +4,15 @@ 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. */
/* When using password but password is disabled, request will always fail. */
/** 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 $password; /* static secret, alternative to token, if enabled */
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 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 */
@@ -30,42 +29,45 @@ final class Payload {
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 or nonce */
if (strlen($data->id ?? '') < 1 ||
strlen($data->nonce ?? '') < 1 ||
/* if missing both token and password (we require one of them) */
(strlen($data->token ?? '') < 1 &&
strlen($data->password ?? '') < 1)
/* 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 = $data->id;
$payload->nonce = $data->nonce;
$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;
/* we accept either a token or a password, not both */
if (strlen($data->token ?? '') > 0) {
$payload->token = $data->token;
$payload->password = null;
} else {
$payload->token = null;
$payload->password = $data->password;
}
$payload->token = mb_substr(trim($data->token), 0, 128);
return Payload::constrict($payload);
}
public static function constrict(Payload $payload): Payload {
/* When using password, scope will be considered None. */
if ($payload->password) {
$payload->scope = Scope::None;
}
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;
@@ -73,8 +75,4 @@ final class Payload {
return $payload;
}
public function toString(): string {
return json_encode($this);
}
}
+1
View File
@@ -3,6 +3,7 @@ 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';
+1 -1
View File
@@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel {
class Kernel extends BaseKernel {
use MicroKernelTrait;
private PersistCache $persistCache;
+15 -11
View File
@@ -3,38 +3,42 @@ 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 Psr\Log\LoggerInterface;
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 $sessionPool,
private LoggerInterface $logger,
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
) {}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 99)]
public function onKernelRequest(RequestEvent $event): void {
/* check if they sent the preauth cookie */
if ($event->getRequest()->cookies->has($this->cookieName())) {
$cookie = $event->getRequest()->cookies->get($this->cookieName());
/* 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 ($this->sessionPool->hasItem($cookieKey)) {
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */
$id = $this->sessionPool->getItem($cookieKey)->get();
$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']
));
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
+9 -8
View File
@@ -4,21 +4,21 @@ 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 Psr\Log\LoggerInterface;
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 $sessionPool,
private CacheItemPoolInterface $sessionCache,
private ConfigBag $config,
private LoggerInterface $logger,
) {}
/** @throws InvalidArgumentException */
@@ -26,13 +26,14 @@ final readonly class AllowListener {
public function onKernelRequest(RequestEvent $event): void {
if ($this->config->ipTtl() > 0) {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
if ($this->sessionPool->hasItem($ipKey)) {
if ($this->sessionCache->hasItem($ipKey)) {
/* ip address corresponds to valid existing session */
$id = $this->sessionPool->getItem($ipKey)->get();
$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']
));
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
+46 -18
View File
@@ -4,11 +4,13 @@ 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\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
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;
@@ -17,33 +19,59 @@ use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class InterceptListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
public function __construct(
private CacheItemPoolInterface $requestPool,
private ConfigBag $config,
private Environment $twig,
CacheItemPoolInterface $noncePool,
LoggerInterface $logger,
) {
$this->logger = $logger;
$this->noncePool = $noncePool;
}
private ConfigBag $config,
private DomainInterface $domainManager,
private Environment $twig,
) {}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
#[AsEventListener(priority: 55)]
public function onKernelRequest(RequestEvent $event): void {
if ($event->getRequest()) {
/* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited,
* nor submitting login credentials; so present the login page now */
/* 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(),
]);
$event->setResponse(new Response($content, Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html']
));
$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;
}
}
+43 -185
View File
@@ -5,22 +5,19 @@ namespace App\Listener;
use App\ConfigBag;
use App\Data\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Service\DomainInterface;
use App\Service\LoginInterface;
use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
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\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
@@ -28,210 +25,69 @@ use Twig\Error\SyntaxError;
final readonly class LoginListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
use GetTotpTrait;
private CacheItemPoolInterface $requestPool;
private CacheItemPoolInterface $sessionPool;
private RateLimiterFactoryInterface $rateLimiter;
/** @throws InvalidArgumentException */
public function __construct(
ConfigBag $config,
private Environment $twig,
CacheItemPoolInterface $noncePool,
CacheItemPoolInterface $requestPool,
CacheItemPoolInterface $sessionPool,
LoggerInterface $logger,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
) {
$this->config = $config;
$this->requestPool = new MonitorCacheKeys($requestPool);
$this->sessionPool = new MonitorCacheKeys($sessionPool);
$this->noncePool = $noncePool;
$this->logger = $logger;
$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);
$response = null;
if ($payload) {
/* if using token */
if ($payload->token) {
$response = $this->checkToken($payload, $event->getRequest());
} else if ($this->config->staticSecret()) {
$response = $this->checkPassword($payload);
}
}
} else if ($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;
}
/* token or password authentication was successful */
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;
}
$limitReached = $this->logFailure(
$payload ? $payload->toString() : $data,
$event->getRequest()
);
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true));
}
}
/** @throws InvalidArgumentException */
private function checkToken(Payload $payload, Request $request): ?Response {
/* When scope is Ip but ip-access is disabled, scope will 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)) {
/* token is correct */
/* login attempted but unsuccessful, log and block if needed */
$limitReached = $this->logFailure($event->getRequest());
/* if server nonce is found and is valid */
$nonceItem = $this->noncePool->getItem($payload->nonce);
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
$this->noncePool->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']
);
if ($payload->scope !== Scope::None) {
/* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) {
$response->headers->setCookie($this->setCookie($cleanId));
} else if ($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";
}
$response->setContent($content)
->setStatusCode(Response::HTTP_TEMPORARY_REDIRECT)
->headers->set('Location',
"{$request->getPathInfo()}{$request->getQueryString()}"
);
$response->headers->set('Content-Type', $contentType);
}
$this->logger->debug("successful login for: $cleanId");
return $response;
}
}
return null;
$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 : '')
));
}
/** @throws InvalidArgumentException */
private function checkPassword(Payload $payload): ?Response {
/* When using password but password is disabled, request will always fail. */
/* if password is correct */
if (hash_equals($this->config->staticSecret(), $payload->password)) {
/* password is correct */
/* nonce *may* be client provided, but must still be unique */
/* if server/client nonce is acceptable (valid server or unused client) */
$nonceItem = $this->noncePool->getItem($payload->nonce);
if (($nonceItem->isHit() && $nonceItem->get()) || ! $nonceItem->isHit()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginListener::NONCE_TTL); /* keep briefly */
$this->noncePool->save($nonceItem);
/* password authentication successful, grant access and set response */
$cleanId = $this->makeCacheKey($payload->id);
$this->logger->debug("successful login for: $cleanId");
return new Response("hi $cleanId",
Response::HTTP_OK,
['Content-Type' => 'text/plain']
);
}
}
return null;
}
/** @throws InvalidArgumentException */
private function setCookie(string $id): Cookie {
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionPool->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->sessionPool->save($sessionCookie);
return Cookie::create(
name: $this->cookieName(),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
secure: 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->sessionPool->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionPool->save($sessionIp);
}
/** @throws InvalidArgumentException */
private function logFailure(string $data, Request $request): bool {
// TODO use rate-limiting symfony system (also update RejectListener)
$timeframe = (int)floor(time() / $this->getTotp()->getPeriod());
/* hash the data and timeframe, so we do not count duplicates in the same timeframe
* hitting refresh a few times should not lock you out */
$ipKey = $this->makeCacheKey("ip_{$request->getClientIp()}");
$failuresItem = $this->requestPool->getItem($ipKey);
$failures = $failuresItem->get() ?? [];
$failures[hash('xxh3', "$timeframe-$data")] = true;
$limitReached = count($failures) >= $this->config->limit();
$failuresItem->set($failures);
$failuresItem->expiresAfter($limitReached
? $this->config->limitTtl() : $this->config->limitTimeout()
);
$this->requestPool->save($failuresItem);
return $limitReached;
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): Response {
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;
@@ -244,6 +100,8 @@ final readonly class LoginListener {
$answer = [
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
];
if ($json) {
-41
View File
@@ -1,41 +0,0 @@
<?php
namespace App\Listener;
use App\ConfigBag;
use App\Trait\GetTotpTrait;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
final readonly class LookupListener {
use GetTotpTrait;
public function __construct(
ConfigBag $config,
) {
$this->config = $config;
}
#[AsEventListener(priority: 44)]
public function onKernelResponse(ResponseEvent $event): void {
/* if you enable a static secret, you may choose to also enable
* a totp-lookup, which contains a special path, when successfully
* authenticated, instead of returning "hi <your-name>" it returns
* "code <totp-code>" */
if ($this->config->staticSecret() && $this->config->lookupTotp()) {
if (str_contains($event->getRequest()->getPathInfo(),
$this->config->lookupTotp()) &&
$event->getResponse()->getStatusCode() === Response::HTTP_OK
) {
$totp = $this->getTotp();
$next = $totp->at(time() + $totp->getPeriod());
$event->setResponse(new Response(
"next {$next}",
Response::HTTP_OK,
['Content-Type' => 'text/plain']
));
}
}
}
}
+22 -23
View File
@@ -4,45 +4,44 @@ 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 Psr\Log\LoggerInterface;
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;
public function __construct(
private CacheItemPoolInterface $requestPool,
private ConfigBag $config,
private Environment $twig,
private LoggerInterface $logger,
) {}
private RateLimiterFactoryInterface $rateLimiter;
/** @throws SyntaxError|InvalidArgumentException|RuntimeError|LoaderError */
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 {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
/* check if they have made too many failed login attempts */
$failuresItem = $this->requestPool->getItem($ipKey);
if ($failuresItem->isHit()) {
$failures = $failuresItem->get() ?? [];
if (count($failures) >= $this->config->limit()) {
$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']
));
}
$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']
));
}
}
}
+63 -50
View File
@@ -8,18 +8,20 @@ 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. */
/* 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 KEY_LIST = '__key_list';
private const IS_DIRTY = '__is_dirty';
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::IS_DIRTY]);
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
foreach ($items as $item) {
if ( ! $item->isHit()) {
$this->initialize();
@@ -31,11 +33,11 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
/** @throws InvalidArgumentException */
private function initialize(): void {
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$keyList->set([]);
$isDirty->set(false);
$changeList->set([]);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->cache->saveDeferred($changeList);
$this->cache->commit();
}
@@ -46,22 +48,24 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
public function isDirty(): bool {
$isDirty = $this->cache->getItem(self::IS_DIRTY);
return $isDirty->get() ?? false;
public function getChanges(): array {
$changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? [];
}
/** @throws InvalidArgumentException */
public function markClean(): void {
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$isDirty->set(false);
$this->cache->save($isDirty);
$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);
}
@@ -83,20 +87,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
public function deleteItem(string $key): bool {
if ($key === self::KEY_LIST || $key === self::IS_DIRTY) {
throw new OutOfBoundsException(
'Can not delete the private key list or is dirty flag'
);
}
$this->isValid($key);
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$keyValues = $keyList->get();
if (isset($keyValues[$key])) {
unset($keyValues[$key]);
$keyList->set($keyValues);
$isDirty->set(true);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->logChange($key, MonitorCacheKeys::REMOVED);
$this->cache->commit();
}
@@ -104,25 +102,17 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
public function deleteItems(array $keys): bool {
if (in_array(self::KEY_LIST, $keys, true) ||
in_array(self::IS_DIRTY, $keys, true)
) {
throw new OutOfBoundsException(
'Can not delete the private key list or is dirty flag'
);
}
$this->allValid($keys);
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$keyValues = $keyList->get();
foreach ($keys as $key) {
if (isset($keyValues[$key])) {
unset($keyValues[$key]);
$isDirty->set(true);
$this->logChange($key, MonitorCacheKeys::REMOVED);
}
}
$keyList->set($keyValues);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->cache->commit();
return $this->cache->deleteItems($keys);
@@ -140,25 +130,48 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return $this->cache->saveDeferred($item);
}
/** @throws InvalidArgumentException */
private function update(CacheItemInterface $item) {
if ($item->getKey() === self::KEY_LIST || $item->getKey() === self::IS_DIRTY) {
throw new OutOfBoundsException(
'Can not alter the private key list or is dirty flag'
);
}
$keyList = $this->cache->getItem(self::KEY_LIST);
$isDirty = $this->cache->getItem(self::IS_DIRTY);
$keyValues = $keyList->get();
$keyValues[$item->getKey()] = true;
$keyList->set($keyValues);
$isDirty->set(true);
$this->cache->saveDeferred($keyList);
$this->cache->saveDeferred($isDirty);
$this->cache->commit();
}
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);
}
}
+23 -43
View File
@@ -7,69 +7,49 @@ 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 $requestPool;
private MonitorCacheKeys $persistRequestPool;
private MonitorCacheKeys $sessionPool;
private MonitorCacheKeys $persistSessionPool;
private MonitorCacheKeys $sessionCache;
private MonitorCacheKeys $sessionStorage;
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $requestPool,
CacheItemPoolInterface $persistRequestPool,
CacheItemPoolInterface $sessionPool,
CacheItemPoolInterface $persistSessionPool
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $sessionStorage,
) {
$this->requestPool = new MonitorCacheKeys($requestPool);
$this->persistRequestPool = new MonitorCacheKeys($persistRequestPool);
$this->sessionPool = new MonitorCacheKeys($sessionPool);
$this->persistSessionPool = new MonitorCacheKeys($persistSessionPool);
$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->requestPool->getKeys())) {
$items = $this->persistRequestPool->getItems($this->persistRequestPool->getKeys());
if (empty($this->sessionCache->getKeys())) {
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
foreach ($items as $item) {
$this->requestPool->saveDeferred($item);
$this->sessionCache->saveDeferred($item);
}
$this->requestPool->markClean();
$this->requestPool->commit();
}
if (empty($this->sessionPool->getKeys())) {
$items = $this->persistSessionPool->getItems($this->persistSessionPool->getKeys());
foreach ($items as $item) {
$this->sessionPool->saveDeferred($item);
}
$this->sessionPool->markClean();
$this->sessionPool->commit();
$this->sessionCache->markClean();
$this->sessionCache->commit();
}
}
/** @throws InvalidArgumentException */
public function persist(): void {
/* we only need to persist the caches if they contain changes */
if ($this->requestPool->isDirty()) {
$this->requestPool->markClean();
$items = $this->requestPool->getItems($this->requestPool->getKeys());
$this->persistRequestPool->clear();
/* 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) {
$this->persistRequestPool->saveDeferred($item);
if (($changes[$item->getKey()] ?? null) === MonitorCacheKeys::REMOVED) {
$this->sessionStorage->deleteItem($item->getKey());
} else {
$this->sessionStorage->saveDeferred($item);
}
}
$this->persistRequestPool->commit();
}
if ($this->sessionPool->isDirty()) {
$this->sessionPool->markClean();
$items = $this->sessionPool->getItems($this->sessionPool->getKeys());
$this->persistSessionPool->clear();
foreach ($items as $item) {
$this->persistSessionPool->saveDeferred($item);
}
$this->persistSessionPool->commit();
$this->sessionStorage->commit();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Service;
use Exception;
use Psr\Cache\InvalidArgumentException;
/** backup-codes are caseinsensitive 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;
}
+104
View File
@@ -0,0 +1,104 @@
<?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 caseinsensitive 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 */
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();
}
}
+23
View File
@@ -0,0 +1,23 @@
<?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;
}
+120
View File
@@ -0,0 +1,120 @@
<?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;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?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;
}
+148
View File
@@ -0,0 +1,148 @@
<?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()));
} else if ($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);
}
}
+7 -2
View File
@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Trait;
trait CookieNameTrait {
private const COOKIE_NAME = '__Host-Http-Preauth';
private const HEADER_NAME = 'X-Preauth';
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;
}
+7
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -6,10 +7,16 @@ 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()
+16
View File
@@ -0,0 +1,16 @@
<?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;
}
}
+15 -8
View File
@@ -6,17 +6,24 @@ namespace App\Trait;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait {
/* 15 bytes neatly fits in base64 */
private const NONCE_LENGTH = 15;
private const NONCE_TTL = 120;
use HasLoggerTrait;
use StringTrait;
protected readonly CacheItemPoolInterface $noncePool;
protected readonly LoggerInterface $logger;
/* 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 {
@@ -24,7 +31,7 @@ trait MakeNonceTrait {
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
)), '+/', '-_'), '=');
$nonceItem = $this->noncePool->getItem($nonce);
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) {
if ($retries < 1) {
@@ -41,7 +48,7 @@ trait MakeNonceTrait {
$nonceItem->set(true); /* valid */
$nonceItem->expiresAfter(static::NONCE_TTL);
$this->logger->debug("added nonce: $nonce");
$this->noncePool->save($nonceItem);
$this->nonceCache->save($nonceItem);
return $nonce;
}
}
+2 -2
View File
@@ -5,9 +5,9 @@ namespace App\Trait;
trait StringTrait {
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
private const KEY_REGEX = '/[^A-Za-z0-9_.]+/';
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
public function makeCacheKey(string $name): string {
return preg_replace(static::KEY_REGEX, '_', $name);
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
}
}
+1 -7
View File
@@ -48,20 +48,14 @@ final readonly class Utilities {
}
private function showTotp(string $totp): void {
// /* only show this at most, every 5 minutes */
// $suppress = $this->appPool->getItem('suppress');
// if ( ! $suppress->isHit()) {
$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
loading TOTP, because the env is not set, please copy above into TOTP_URI
RAW, FILE_APPEND
);
// $suppress->expiresAfter(300);
// $this->appPool->save($suppress);
// }
}
}
@@ -1,3 +0,0 @@
0
__key_list
a:0:{}
@@ -1,3 +0,0 @@
0
__is_dirty
b:0;
@@ -1,3 +0,0 @@
0
__key_list
a:0:{}
@@ -1,3 +0,0 @@
0
__is_dirty
b:0;
@@ -1,3 +0,0 @@
1766103213
suppress
N;
@@ -1,3 +0,0 @@
32503594112
totp
s:138:"otpauth://totp/Preauth-TOTP?secret=5RL5FOJGV4XRKGT74ZVN4725OAM244SU7JYXYX4SHQDTJI4P3YKBYAFUVBBOCLI5XSOLERNB6IQQ54SIGY6QHJ26JM4OP3ZJVBUUBIY";
+15
View File
@@ -1,4 +1,19 @@
{
"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": {
+84
View File
@@ -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>
+1 -1
View File
@@ -1,4 +1,4 @@
<style>
<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%; }
+2 -2
View File
@@ -4,9 +4,9 @@
<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') }}
{{- include('_style.html.twig') -}}
</head>
<body>
<body id="preauth-body">
{% block content %}{% endblock %}
</body>
</html>
+10 -116
View File
@@ -3,123 +3,17 @@
{% block content %}
<h1>{{ env.title }}</h1>
<p id="preauth-message">{{ message|default }}</p>
<form id="preauth-form">
<input id="preauth-nonce" type="hidden" name="preauth_nonce" value="{{ nonce }}">
<div class="right"><label for="preauth-id">{{ env.id_name }}:</label></div>
<div><input type="text" name="preauth_id" id="preauth-id"
autocomplete="username" required="required" autofocus="autofocus"></div>
{% if env.allow_password %}
<div class="right boxTk">
<label for="preauth-token">{{ env.token_name }}:</label><br>
<span id="preauth-use-pw">🔃 {{ env.password_name }}</span></div>
<div class="boxTk"><input type="text" name="preauth_token" id="preauth-token"
autocomplete="one-time-code" required="required"></div>
<div class="right boxPw hidden">
<label for="preauth-password">{{ env.password_name }}:</label><br>
<span id="preauth-use-tk">🔃 {{ env.token_name }}</span></div>
<div class="boxPw hidden"><input type="password" name="preauth_password" id="preauth-password"
autocomplete="password" disabled="disabled" required="required"></div>
{% else %}
<div class="right"><label for="preauth-token">{{ env.token_name }}:</label></div>
<div><input type="text" name="preauth_token" id="preauth-token"
autocomplete="one-time-code" required="required"></div>
{% endif %}
<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>
<script>
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
{% if env.allow_password %}
const usePw = document.getElementById('preauth-use-pw');
const useTk = document.getElementById('preauth-use-tk');
const boxPw = document.querySelectorAll('.boxPw');
const boxTk = document.querySelectorAll('.boxTk');
usePw.addEventListener('click', (event) => {
form.preauth_token.value = '';
form.preauth_token.disabled = true;
form.preauth_password.disabled = false;
boxTk.forEach((element) => {
element.classList.add('hidden');
});
boxPw.forEach((element) => {
element.classList.remove('hidden');
});
});
useTk.addEventListener('click', (event) => {
form.preauth_password.value = '';
form.preauth_password.disabled = true;
form.preauth_token.disabled = false;
boxPw.forEach((element) => {
element.classList.add('hidden');
});
boxTk.forEach((element) => {
element.classList.remove('hidden');
});
});
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
form.addEventListener('submit', (event) => {
event.preventDefault();
{% if env.allow_password %}
/* make base64url string containing our payload json object */
/* payload will contain either token or password */
const data = btoa(JSON.stringify({
id: form.preauth_id.value,
...( form.preauth_token.value && { token: form.preauth_token.value }),
...(( ! form.preauth_token.value) && { password: form.preauth_password.value }),
nonce: form.preauth_nonce.value,
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
{% else %}
/* make base64url string containing our payload json object */
const data = btoa(JSON.stringify({
id: form.preauth_id.value,
token: form.preauth_token.value,
nonce: form.preauth_nonce.value,
json: true
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
{% endif %}
/* send our request to the server */
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
}).then((response) => {
if (response.headers.has('Location')) {
/* follow redirect (not needed in most browsers) */
window.location.href = response.headers.get('Location');
} else if (response.headers.get('Content-Type') === 'application/json') {
/* got json, update the page */
response.json().then((content) => {
if (Object.hasOwn(content, 'message')) {
message.innerText = content.message;
}
if (Object.hasOwn(content, 'nonce')) {
form.preauth_nonce.value = content.nonce;
form.preauth_token.value = '';
form.preauth_token.focus();
}
}).catch((error) => {
console.log('failed to parse json from response');
console.log(error);
});
} else { /* non-json, non-redirect response */
/* overwrite the page */
response.text().then((text) => {
document.open();
document.write(text);
document.close();
}).catch((error) => {
console.log('failed to get text from response');
console.log(error);
});
}
});
});
</script>
{% endblock %}
+485
View File
@@ -0,0 +1,485 @@
<?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);
}
}
+111
View File
@@ -0,0 +1,111 @@
<?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;
}
};
}
}
+89
View File
@@ -0,0 +1,89 @@
<?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);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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');
}
}
}
});
}
}
+20
View File
@@ -0,0 +1,20 @@
<?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,120 @@
<?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 "singleuse",
// so assert against the substring to avoid encoding fragility
self::assertStringContainsString('backup codes', $command->getDescription());
}
}
+90
View File
@@ -0,0 +1,90 @@
<?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());
}
}
+195
View File
@@ -0,0 +1,195 @@
<?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']);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?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(''));
}
}
+124
View File
@@ -0,0 +1,124 @@
<?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());
}
}
+95
View File
@@ -0,0 +1,95 @@
<?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,206 @@
<?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');
}
}
+295
View File
@@ -0,0 +1,295 @@
<?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,89 @@
<?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 &#039;
self::assertStringContainsString('a teapot', $content);
self::assertStringContainsString('I refuse to brew coffee', $content);
}
}
+273
View File
@@ -0,0 +1,273 @@
<?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());
}
}
+220
View File
@@ -0,0 +1,220 @@
<?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,215 @@
<?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]));
}
}
+241
View File
@@ -0,0 +1,241 @@
<?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'));
}
}
+424
View File
@@ -0,0 +1,424 @@
<?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);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?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());
}
}
+90
View File
@@ -0,0 +1,90 @@
<?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();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?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);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
use App\Trait\MakeNonceTrait;
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\HttpKernel\Exception\HttpException;
/**
* Wraps the trait in a concrete class with public proxies so the protected
* methods can be exercised from test scope.
*/
final class MakeNonceTraitTest extends TestCase {
private function makeObject(): object {
return new class {
use MakeNonceTrait;
public function publicMakeNonce(int $retries = 3): string {
return $this->makeNonce($retries);
}
public function publicMakeCacheKey(string $name): string {
return $this->makeCacheKey($name);
}
};
}
public function testMakeNonceReturnsBase64UrlString(): void {
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache(new ArrayAdapter());
$nonce = $obj->publicMakeNonce();
self::assertIsString($nonce);
// 15 bytes -> 20 base64 chars without padding
self::assertSame(20, strlen($nonce));
// base64url charset only
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
}
public function testMakeNonceStoresNonceInCache(): void {
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($pool);
$nonce = $obj->publicMakeNonce();
// makeNonce stores via makeCacheKey() which rewrites '-' to '_'
$key = $obj->publicMakeCacheKey($nonce);
self::assertTrue($pool->hasItem($key));
$item = $pool->getItem($key);
self::assertTrue($item->get());
}
public function testMakeNonceSetsExpiry(): void {
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($pool);
$nonce = $obj->publicMakeNonce();
$item = $pool->getItem($obj->publicMakeCacheKey($nonce));
$expiry = $item->getMetadata()['expiry'];
// NONCE_TTL is 120 seconds
self::assertLessThanOrEqual(120, (int) $expiry - time());
self::assertGreaterThan(time(), (int) $expiry);
}
public function testTwoNoncesAreDifferent(): void {
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($pool);
$nonce1 = $obj->publicMakeNonce();
$nonce2 = $obj->publicMakeNonce();
self::assertNotSame($nonce1, $nonce2);
}
public function testMakeNonceThrowsAfterMaxRetries(): void {
// Create a stub pool that always reports every key as a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn(true);
$pool->method('getItem')->willReturn($item);
$pool->method('save')->willReturn(true);
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($pool);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Internal Server Error');
$obj->publicMakeNonce();
}
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void {
// Use a spy pool that returns isHit=true on the first getItem call
// (simulating a collision), then delegates to a real ArrayAdapter for
// subsequent calls so the retry succeeds.
$realPool = new ArrayAdapter();
$collisionCount = 0;
$spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
private int $hits = 0;
public function __construct(
private CacheItemPoolInterface $inner,
private int &$hitCounter,
) {}
public function getItem(string $key): CacheItemInterface {
$item = $this->inner->getItem($key);
// pretend the first requested key is already a hit (collision)
if ($this->hits === 0) {
$this->hits++;
$this->hitCounter++;
return new class($key) implements CacheItemInterface {
public function __construct(private string $key) {}
public function getKey(): string { return $this->key; }
public function get(): mixed { return true; }
public function isHit(): bool { return true; }
public function set(mixed $value): static { return $this; }
public function expiresAt(?\DateTimeInterface $expiration): static { return $this; }
public function expiresAfter(int|\DateInterval|null $time): static { return $this; }
};
}
return $item;
}
public function getItems(array $keys = []): iterable { return $this->inner->getItems($keys); }
public function hasItem(string $key): bool { return $this->inner->hasItem($key); }
public function clear(): bool { return $this->inner->clear(); }
public function deleteItem(string $key): bool { return $this->inner->deleteItem($key); }
public function deleteItems(array $keys): bool { return $this->inner->deleteItems($keys); }
public function save(CacheItemInterface $item): bool { return $this->inner->save($item); }
public function saveDeferred(CacheItemInterface $item): bool { return $this->inner->saveDeferred($item); }
public function commit(): bool { return $this->inner->commit(); }
};
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($spyPool);
// should retry and succeed on the second attempt
$nonce = $obj->publicMakeNonce();
self::assertIsString($nonce);
self::assertSame(20, strlen($nonce));
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
}
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void {
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn(true);
$pool->method('getItem')->willReturn($item);
$pool->method('save')->willReturn(true);
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($pool);
$this->expectException(HttpException::class);
$obj->publicMakeNonce(0);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase;
final class StringTraitTest extends TestCase {
use StringTrait;
public function testMakeCacheKeySanitizesInvalidChars(): void {
self::assertSame('hello_world', $this->makeCacheKey('hello world'));
self::assertSame('hello_world', $this->makeCacheKey('hello!world'));
self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d'));
}
public function testMakeCacheKeyPreservesValidChars(): void {
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
}
public function testMakeCacheKeyTruncatesLongNames(): void {
$long = str_repeat('a', 300);
$result = $this->makeCacheKey($long);
self::assertSame(128, mb_strlen($result));
}
public function testMakeCacheKeyEmptyString(): void {
self::assertSame('', $this->makeCacheKey(''));
}
public function testMakeCacheKeyWithOnlyInvalidChars(): void {
// preg_replace with + collapses consecutive invalid chars into one _
self::assertSame('_', $this->makeCacheKey('!!!'));
self::assertSame('_', $this->makeCacheKey(' '));
self::assertSame('_', $this->makeCacheKey('!@#'));
self::assertSame('_', $this->makeCacheKey('!@ #'));
}
public function testMakeCacheKeyTruncatesToExactly128(): void {
$input = str_repeat('a', 128);
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
self::assertSame($input, $this->makeCacheKey($input));
$input129 = str_repeat('a', 129);
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
}
public function testMakeCacheKeyWithMultibyteChars(): void {
// multibyte chars are replaced with a single underscore
$result = $this->makeCacheKey('héllo wörld');
// é and ö are not in [A-Za-z0-9_.] so they become _
self::assertSame('h_llo_w_rld', $result);
}
public function testMakeCacheKeyWithEmoji(): void {
$result = $this->makeCacheKey('a🎉b');
self::assertSame('a_b', $result);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Utilities;
use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class UtilitiesTest extends TestCase {
private function makeUtilities(?ArrayAdapter $pool = null, ?ClockInterface $clock = null): Utilities {
$pool ??= new ArrayAdapter();
$clock ??= $this->createStub(ClockInterface::class);
return new Utilities($clock, $pool);
}
public function testLoadTotpReturnsCachedValueWhenPresent(): void {
$pool = new ArrayAdapter();
$item = $pool->getItem('totp');
$item->set('otpauth://totp/cached?secret=ABCDEFGH');
$pool->save($item);
$utilities = $this->makeUtilities($pool);
$result = $utilities->loadTotp();
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
}
public function testLoadTotpGeneratesAndStoresWhenMissing(): void {
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
$result = $utilities->loadTotp();
self::assertNotEmpty($result);
self::assertStringStartsWith('otpauth://totp/', $result);
// stored in cache for next boot
$cached = $pool->getItem('totp');
self::assertTrue($cached->isHit());
self::assertSame($result, $cached->get());
}
public function testLoadTotpSetsFarFutureExpiry(): void {
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
$utilities->loadTotp();
$cached = $pool->getItem('totp');
$expiry = $cached->getMetadata()['expiry'];
// 2999-12-31 is well in the future, far beyond any reasonable test timestamp
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
}
public function testLoadTotpIsIdempotentAfterGeneration(): void {
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
$first = $utilities->loadTotp();
// second call should find it in cache and return the same value
$second = $utilities->loadTotp();
self::assertSame($first, $second);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env.test');
}
if (!isset($_SERVER['APP_DEBUG'])) {
$_SERVER['APP_DEBUG'] = false;
}