Files
preauth/tests/Unit/Service/LoginManagerTest.php
T
lyra fed7b1b48c Extract session issuing so both login paths share it
SessionIssuer now owns 'grant access after authenticating', which LoginManager
previously did internally. The passkey ceremony needs the same behaviour, and
two implementations would inevitably drift — most likely in cookie attributes,
where a difference stays invisible until it breaks in a browser.

LoginManager keeps what is specific to code-based login: verifying the TOTP or
backup code and enforcing the single-use nonce. Its 18 existing tests pass
unchanged, which is the evidence that this is behaviour-preserving rather than a
rewrite.

Also folds the redundant early-return into a single guard in checkToken so the
success path reads straight through.
2026-09-27 10:45:58 +00:00

466 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\Service\SessionIssuer;
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 ?SessionIssuer $sessionIssuer = null;
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);
/* session issuing is shared with the passkey flow, so it is built as the
* same collaborator the container would inject */
$this->sessionIssuer = new SessionIssuer($this->pool, $this->domainManager, $this->makeConfig(ipTtl: $ipTtl));
$this->sessionIssuer->setLogger(new NullLogger());
$manager = new LoginManager($this->backupCodeManager, $this->sessionIssuer);
$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
$issuer = $this->sessionIssuer;
self::assertNotNull($issuer, 'makeLoginManager() should have built a session issuer.');
$reflection = new ReflectionProperty(SessionIssuer::class, 'sessionCache');
$sessionCache = $reflection->getValue($issuer);
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);
/* the colliding pool must be the one the session issuer writes through,
* because that is where the cookie is stored */
$issuer = new SessionIssuer($pool, $this->domainManager, $this->makeConfig());
$issuer->setLogger(new NullLogger());
$manager = new LoginManager($this->backupCodeManager, $issuer);
$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);
}
}