Tests / test (pull_request) Successful in 1m10s
Brings preauth from 18/34 to 30/34 conformance (auth-gateway profile). The
remaining four checks all depend on files this branch cannot change (see below).
PHP toolchain (§1)
- require.php >=8.4 -> ^8.5, and pin config.platform to 8.5.0. The old
constraint also permitted PHP 9, which is not a promise we can keep.
composer.lock regenerated with --lock: content-hash + platform-overrides
only, zero dependency version movement.
- friendsofphp/php-cs-fixer * -> ^3.95. A wildcard meant CI was not
reproducible.
PHPStan (§2.2)
- vendor the shared phpstan.neon.dist (level 6) + a generated baseline.
187 errors are captured rather than fixed; the baseline should only shrink
from here.
- add phpstan/phpstan:^2.1 to require-dev.
Code style (§8.2)
- vendor the shared .php-cs-fixer.dist.php (@Symfony + @Symfony:risky +
declare_strict_types) and apply it: 59 of 67 files reformatted.
- Verified this is a formatting change, not a behaviour change: all 313 tests
pass after the reformat, all in_array() calls already passed strict=true,
and the remaining edits are @Symfony:risky idiom (yoda conditions, \count(),
self:: over the class name).
Repository layout (§4.4)
- docs/{Caddyfile,compose.yaml,example.env} -> docs/examples/, with
example.env becoming the conventional .env.example. This is the layout
GUIDING-LIGHT already cites preauth as doing correctly — it just needed
renaming.
- update the four readme.md references and a stale compose.yaml comment.
- docs/v1.1-plan.md references are left alone deliberately: it is a historical
plan recording what was done at the time, not live documentation.
Licence and security policy (§7)
- add LICENSE (uniform MIT, matching composer.json).
- add SECURITY.md describing the actual threat model: per-request
allow/intercept, no caching of the login flow, app-set security headers,
TOTP, and the fact that REMOTE_USER is trusted input.
Mobile accessibility (§3.3a)
- templates/base.html.twig: drop maximum-scale=1 and add viewport-fit=cover.
preauth was the one app already past the font-size precondition (controls
render at 21.6px = 0.9em x 24px), so removing the lock is safe here and
restores pinch-zoom for Android users.
Conformance tooling (§8.2)
- vendor .ci/conformance.sh and .ci/css-control-size.py so the check runs
from a checkout rather than fetching from the LAN-only private/ci.
- .editorconfig synced from the version that keeps the Caddyfile tab rule.
Not included (blocked by the .gitea/workflows pre-receive hook):
- ci-composer-audit, ci-composer-validate, ci-reusable-workflows.
Workflow files may only change via a trusted ref, so the caller files are
staged but not committed.
Also not included: dockerfile-nonroot (§6.4). Adding USER to an image with
VOLUME [/config, /data] changes volume ownership and needs an actual container
build/run to verify, so it goes in its own change.
452 lines
17 KiB
PHP
452 lines
17 KiB
PHP
<?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\Service\LoginManager;
|
|
use App\Tests\Support\TotpTestHelper;
|
|
use App\Trait\StringTrait;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Cache\CacheItemInterface;
|
|
use Psr\Cache\CacheItemPoolInterface;
|
|
use Psr\Log\NullLogger;
|
|
use ReflectionProperty;
|
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
final class LoginManagerTest extends TestCase
|
|
{
|
|
use StringTrait;
|
|
use TotpTestHelper;
|
|
|
|
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 test_check_token_returns_null_for_invalid_totp(): 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 test_check_token_returns_null_for_spent_nonce(): 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 test_check_token_returns_null_for_missing_nonce(): 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 test_successful_totp_login_with_cookie_scope_returns_redirect(): 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 test_successful_login_with_none_scope_returns_plain_response(): 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 test_successful_login_sets_remote_user_header(): 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 test_successful_login_json_response(): 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 test_successful_login_html_response(): 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 test_successful_login_with_return_url(): 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 test_successful_login_with_invalid_return_falls_back_to_path(): 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 test_ip_scope_downgrades_to_cookie_when_ip_access_disabled(): 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 test_ip_scope_when_enabled_sets_ip_session(): 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 test_backup_code_authentication(): 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 test_nonce_is_consumed_after_successful_login(): 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 test_ulid_collision_throws_http_exception(): 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(static 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 ArrayAdapter();
|
|
|
|
return $realPool->getItem($key);
|
|
});
|
|
$pool->method('hasItem')->willReturnCallback(static function (string $key) {
|
|
if (str_starts_with($key, 'cookie_')) {
|
|
return true;
|
|
}
|
|
static $realPool = null;
|
|
$realPool ??= new ArrayAdapter();
|
|
|
|
return $realPool->hasItem($key);
|
|
});
|
|
$pool->method('save')->willReturn(true);
|
|
$pool->method('saveDeferred')->willReturn(true);
|
|
$pool->method('commit')->willReturn(true);
|
|
$pool->method('getItems')->willReturnCallback(static function (array $keys) {
|
|
static $realPool = null;
|
|
$realPool ??= new 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 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 test_cookie_scope_with_central_auth_sets_domain_on_matching_host(): 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 test_cookie_scope_with_central_auth_on_non_matching_host_uses_null_domain(): 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 test_check_token_with_empty_return_parameter_falls_back_to_path(): 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);
|
|
}
|
|
}
|