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.
466 lines
17 KiB
PHP
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);
|
|
}
|
|
}
|