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); } }