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.
This commit is contained in:
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\Enum\Scope;
|
||||
use App\Listener\PasskeyListener;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
@@ -41,8 +41,6 @@ final class PasskeyListenerTest extends TestCase
|
||||
|
||||
private const string AUTH_HOST = 'auth.example.com';
|
||||
|
||||
private ?ArrayAdapter $sessionCache = null;
|
||||
|
||||
private function makeListener(
|
||||
?PasskeyInterface $passkeys = null,
|
||||
bool $available = true,
|
||||
@@ -50,8 +48,6 @@ final class PasskeyListenerTest extends TestCase
|
||||
?DomainInterface $domainManager = null,
|
||||
int $beginLimit = 30,
|
||||
): PasskeyListener {
|
||||
$this->sessionCache = new ArrayAdapter();
|
||||
|
||||
$policy = $this->createStub(PasskeyPolicyInterface::class);
|
||||
$policy->method('isAvailableFor')->willReturn($available);
|
||||
|
||||
@@ -60,7 +56,6 @@ final class PasskeyListenerTest extends TestCase
|
||||
$listener = new PasskeyListener(
|
||||
new RateLimiterFactory(['id' => 'passkey_begin_burst', 'policy' => 'sliding_window', 'limit' => $beginLimit, 'interval' => '60 seconds'], new InMemoryStorage()),
|
||||
new RateLimiterFactory(['id' => 'login_limiter', 'policy' => 'sliding_window', 'limit' => 2, 'interval' => '60 seconds'], new InMemoryStorage()),
|
||||
$this->sessionCache,
|
||||
$passkeys ?? $this->createStub(PasskeyInterface::class),
|
||||
$policy,
|
||||
$sessionIssuer ?? $this->createStub(SessionIssuerInterface::class),
|
||||
@@ -252,16 +247,22 @@ final class PasskeyListenerTest extends TestCase
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame('1', $event->getResponse()?->headers->get(PasskeyListener::CEREMONY_MARKER));
|
||||
self::assertSame('1', $event->getResponse()?->headers->get(AppConstants::PASSKEY_CEREMONY_MARKER));
|
||||
}
|
||||
|
||||
/* ── registration gating ──────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Registration requires a live session: the identity comes from the cookie,
|
||||
* never from the body, so nobody can register a passkey for another identity.
|
||||
* /**
|
||||
* `register-begin` deliberately has no handler here.
|
||||
*
|
||||
* A registration ceremony may only start after a valid TOTP code, which is
|
||||
* presented to `LoginManager` as part of the form submission — so
|
||||
* `LoginManager` starts it. Exposing it on this listener would hand out a
|
||||
* challenge without proving anything, which is precisely the hole the
|
||||
* design closes. This test pins that the operation is *not* honoured.
|
||||
*/
|
||||
public function test_register_begin_without_a_session_is_refused(): void
|
||||
public function test_register_begin_is_not_a_listener_operation(): void
|
||||
{
|
||||
$passkeys = $this->createMock(PasskeyInterface::class);
|
||||
$passkeys->expects(self::never())->method('beginRegistration');
|
||||
@@ -274,33 +275,6 @@ final class PasskeyListenerTest extends TestCase
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_register_begin_uses_the_identity_from_the_session_cookie(): void
|
||||
{
|
||||
$passkeys = $this->createMock(PasskeyInterface::class);
|
||||
$passkeys->expects(self::once())
|
||||
->method('beginRegistration')
|
||||
->with('lyra')
|
||||
->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
|
||||
/* seed a live session the way SessionIssuer would have; this has to
|
||||
* happen after makeListener(), which builds the pool the listener holds */
|
||||
$ulid = 'test-ulid';
|
||||
$monitor = new MonitorCacheKeys($this->sessionCache);
|
||||
$item = $monitor->getItem($this->makeCacheKey("cookie_$ulid"));
|
||||
$item->set('lyra');
|
||||
$monitor->save($item);
|
||||
|
||||
$request = $this->ceremonyRequest('register-begin');
|
||||
$request->cookies->set('__Http-Domain-Preauth', $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── failures share the login budget (D3) ─────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\PasskeyListener;
|
||||
use App\AppConstants;
|
||||
use App\Listener\SecurityHeadersListener;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
@@ -252,11 +252,11 @@ final class SecurityHeadersListenerTest extends TestCase
|
||||
{
|
||||
$listener = $this->makeListener('auth.example.com');
|
||||
$response = new Response('{}', Response::HTTP_OK);
|
||||
$response->headers->set(PasskeyListener::CEREMONY_MARKER, '1');
|
||||
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
|
||||
|
||||
$listener->onKernelResponse($this->makeEvent($response));
|
||||
|
||||
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
|
||||
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
|
||||
self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
|
||||
self::assertSame('*', $response->headers->get('Vary'));
|
||||
}
|
||||
@@ -279,6 +279,6 @@ final class SecurityHeadersListenerTest extends TestCase
|
||||
self::assertStringNotContainsString('no-store', $cacheControl);
|
||||
self::assertStringContainsString('max-age=60', $cacheControl);
|
||||
self::assertFalse($response->headers->has('Surrogate-Control'));
|
||||
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
|
||||
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -45,7 +46,7 @@ final class LoginManagerTest extends TestCase
|
||||
$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 = new LoginManager($this->backupCodeManager, $this->sessionIssuer, $this->createStub(PasskeyInterface::class));
|
||||
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new ArrayAdapter());
|
||||
@@ -375,7 +376,7 @@ final class LoginManagerTest extends TestCase
|
||||
$issuer = new SessionIssuer($pool, $this->domainManager, $this->makeConfig());
|
||||
$issuer->setLogger(new NullLogger());
|
||||
|
||||
$manager = new LoginManager($this->backupCodeManager, $issuer);
|
||||
$manager = new LoginManager($this->backupCodeManager, $issuer, $this->createStub(PasskeyInterface::class));
|
||||
$manager->setConfig($this->makeConfig());
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new ArrayAdapter());
|
||||
|
||||
Reference in New Issue
Block a user