15 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
73 changed files with 7360 additions and 570 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 -2
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 && \
@@ -31,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 && \
@@ -51,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';
+15 -5
View File
@@ -1,18 +1,18 @@
{
"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.3",
"runtime/frankenphp-symfony": "^0.2.0",
"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.10",
"symfony/flex": "^2.11",
"symfony/framework-bundle": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/rate-limiter": "7.4.*",
@@ -35,6 +35,11 @@
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
@@ -68,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
+2460 -245
View File
File diff suppressed because it is too large Load Diff
+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 -5
View File
@@ -7,16 +7,12 @@ twig:
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)%'
+25 -17
View File
@@ -8,37 +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
# default is the lower of 2 per half-minute 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)
# 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
# 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
@@ -48,9 +51,14 @@ parameters:
env(TOO_MANY_TITLE): 'Too many requests'
env(TOO_MANY_MESSAGE): 'Try again later'
app.cookie_ttl: '%env(COOKIE_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.teapot: '%env(TEAPOT)%'
+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 -12
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,16 +8,14 @@
# 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
# 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)
# --- 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
@@ -26,14 +24,23 @@
# 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
@@ -43,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;
+19
View File
@@ -25,7 +25,26 @@ First time you spin up the docker container it will generate a TOTP secret (whic
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 -22
View File
@@ -10,7 +10,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ConfigBag {
private ClockInterface $clock;
private int $cookieTtl;
private string $queryPrefix;
private string $totpUri;
private ?int $ipTtl;
private bool $teapot;
@@ -20,25 +19,23 @@ final readonly class ConfigBag {
/** @throws InvalidArgumentException */
public function __construct(
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.query_prefix%')] string $queryPrefix,
#[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,
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->queryPrefix = $queryPrefix;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: 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;
}
@@ -50,10 +47,6 @@ final readonly class ConfigBag {
return $this->cookieTtl;
}
public function query(string $field): string {
return "$this->queryPrefix$field";
}
public function totpUri(): string {
return $this->totpUri;
}
+28 -14
View File
@@ -4,12 +4,12 @@ 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 $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 */
@@ -29,27 +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, nonce, or token */
if (strlen($data->id ?? '') < 1 ||
strlen($data->nonce ?? '') < 1 ||
strlen($data->token ?? '') < 1
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;
$payload->token = $data->token;
$payload->token = mb_substr(trim($data->token), 0, 128);
return Payload::constrict($payload);
}
public static function constrict(Payload $payload): Payload {
public function toString(): string {
return json_encode($this);
}
private static function constrict(Payload $payload): Payload {
/* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) {
$payload->json = false;
@@ -57,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;
+11 -7
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
@@ -19,22 +20,25 @@ final readonly class AcceptListener {
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
) {}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 99)]
public function onKernelRequest(RequestEvent $event): void {
/* check if they sent the 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->sessionCache->hasItem($cookieKey)) {
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */
$id = $this->sessionCache->getItem($cookieKey)->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse(new Response("hi $id",
headers: ['Content-Type' => 'text/plain']
));
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
+4 -3
View File
@@ -30,9 +30,10 @@ final readonly class AllowListener {
/* ip address corresponds to valid existing session */
$id = $this->sessionCache->getItem($ipKey)->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse(new Response("hi $id",
headers: ['Content-Type' => 'text/plain']
));
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]));
}
}
}
+43 -9
View File
@@ -4,10 +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\InvalidArgumentException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Twig\Environment;
@@ -16,28 +19,59 @@ use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class InterceptListener {
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
public function __construct(
private ConfigBag $config,
private Environment $twig,
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;
}
}
+33 -118
View File
@@ -3,25 +3,21 @@ declare(strict_types=1);
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 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\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Uid\Ulid;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
@@ -32,140 +28,57 @@ final readonly class LoginListener {
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
use GetTotpTrait;
private CacheItemPoolInterface $sessionCache;
private RateLimiterFactoryInterface $rateLimiter;
/** @throws InvalidArgumentException */
public function __construct(
private Environment $twig,
CacheItemPoolInterface $sessionCache,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$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 && $payload->token) {
$response = $this->checkToken($payload, $event->getRequest());
}
} 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($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->nonceCache->getItem($payload->nonce);
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginListener::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']
);
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;
}
/** @throws InvalidArgumentException */
private function setCookie(string $id): 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);
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->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
));
}
private function logFailure(Request $request): bool {
@@ -174,7 +87,7 @@ final readonly class LoginListener {
}
/** @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;
@@ -187,6 +100,8 @@ final readonly class LoginListener {
$answer = [
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
];
if ($json) {
+7 -7
View File
@@ -8,13 +8,13 @@ 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 CHANGE_LIST = '__chg_list';
public const UPDATED = 1;
public const REMOVED = 2;
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;
@@ -135,7 +135,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException|OutOfBoundsException */
private function update(CacheItemInterface $item) {
private function update(CacheItemInterface $item): void {
$this->isValid($item->getKey());
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
+1
View File
@@ -7,6 +7,7 @@ use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/* need autoconfigure so we get it from the service container in Kernel->boot() */
#[Autoconfigure(public: true)]
final readonly class PersistCache {
private MonitorCacheKeys $sessionCache;
+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;
}
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
+4 -3
View File
@@ -12,10 +12,11 @@ use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait {
use HasLoggerTrait;
use StringTrait;
/* 15 bytes neatly fits in base64 */
private const NONCE_LENGTH = 15;
private const NONCE_TTL = 120;
private const int NONCE_LENGTH = 15;
private const int NONCE_TTL = 120;
protected readonly CacheItemPoolInterface $nonceCache;
@@ -30,7 +31,7 @@ trait MakeNonceTrait {
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
)), '+/', '-_'), '=');
$nonceItem = $this->nonceCache->getItem($nonce);
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) {
if ($retries < 1) {
+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);
// }
}
}
+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": {
+78 -46
View File
@@ -1,52 +1,84 @@
<script>
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
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();
form.addEventListener('submit', (event) => {
event.preventDefault();
/* 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(/=+$/, '');
{# 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 (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);
});
}
});
{# 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 -3
View File
@@ -4,10 +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 %}
{{ include('_script.html.twig') }}
</body>
</html>
+11 -8
View File
@@ -3,14 +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>
<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>
<form id="preauth-form" {% if post ?? false -%} method="post" {%- endif %}>
<input id="nonce" type="hidden" name="nonce" value="{{ nonce }}">
<div class="right"><label for="username">{{ env.id_name }}:</label></div>
<div><input type="text" name="username" id="username" {% if username ?? false %}value="{{ username }}"{% endif %}
autocomplete="username" required="required" autofocus="autofocus"></div>
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
<div><input type="text" name="totp" id="totp"
autocomplete="one-time-code" required="required"></div>
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
{% endblock %}
+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;
}