Files
preauth/tests/Unit/Service/LoginManagerTest.php
T
lyra 69ee5e99aa Route passkey registration through the TOTP check, not the listener
Corrects a design error in the previous commit. I had exposed register-begin as
a listener operation and gated it on a session cookie, but the approved flow has
no session at that point: the whole point is that a valid TOTP code is what
authorises registration, and the session is only issued once the new credential
has been verified.

Two consequences, both bad:

- There is no session cookie to check, so the gate could never have worked. It
  would have been dead code that looked like a security control.
- More seriously, a listener-side register-begin would hand out a challenge
  without proving anything. Anyone could obtain ceremony options and attempt
  registration. The cookie check was not a weak control; the operation itself
  was the hole.

Registration is now started by LoginManager, after it has verified both the code
and the nonce, and its options are returned with the login response. That is the
flow in the plan, and it keeps nonce validation in the one place that already
enforces it. The capability for register-finish is the single-use ceremonyId,
which is server-issued and bound to the identity that passed the check.

The listener now serves three operations, and a test pins that register-begin is
not one of them.

Also adds Payload::$register so the checkbox intent survives from the form to
LoginManager, and moves the ceremony marker constant to AppConstants since both
LoginManager and the listener now produce marked responses.
2026-09-27 11:29:17 +00:00

467 lines
18 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\PasskeyInterface;
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, $this->createStub(PasskeyInterface::class));
$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, $this->createStub(PasskeyInterface::class));
$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);
}
}