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.
467 lines
18 KiB
PHP
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);
|
|
}
|
|
}
|