Files
preauth/src/Service/LoginManager.php
T
lyra ffe6870231 Add end-to-end functional tests with real cryptography
The whole flow through the real HTTP kernel, with nothing about the ceremony
stubbed: registration builds a genuine CBOR attestation object signed by a real
P-256 key, and login signs a real assertion. Only the browser's plumbing is
simulated — the fetch() calls become requests, which is the seam worth testing.

Covered: a real registration grants a session; a real passkey login grants a
session and reports the right Remote-User; a bad TOTP starts no ceremony; a
spent nonce starts no ceremony; a replayed ceremony fails; an assertion for
another challenge fails; an unknown credential fails with the same generic
message a wrong code gets; both login paths set an identical cookie; ceremony
responses are not cacheable and do not leak their marker; the ceremony is inert
when disabled; the page offers passkeys only when enabled; and the CSP permits
the two WebAuthn directives.

Writing these found a real bug. The registration checkbox originally submitted
a plain form POST, which returns HTML — and, more importantly, loses the fresh
nonce the failure response issues. The user's next attempt would then fail
against a nonce that had already been spent, with no visible reason why. It now
goes through the same X-Preauth AJAX path as an ordinary login, so failures come
back as JSON with a usable nonce; a non-JSON submission is treated as an
ordinary login, and LoginManager returns null for it rather than starting a
ceremony that nothing could finish.

Three test failures were also correct behaviour rather than bugs: once a session
cookie exists, AcceptListener (priority 99) answers before any ceremony listener
runs, so tests exercising a second ceremony need a visitor without that cookie.
That is the intended ordering, now documented in the tests.
2026-09-27 11:35:54 +00:00

128 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\AppConstants;
use App\Data\Payload;
use App\Enum\Scope;
use App\Trait\GetTotpTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Override;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Authenticates a TOTP code (or backup code) and, on success, either grants
* access or starts a passkey registration.
*
* The "grant access" half lives in {@see SessionIssuer} so the passkey ceremony
* produces an identical response. This class keeps the part genuinely specific
* to code-based login: verifying the code and enforcing the single-use nonce.
*
* **Why the registration hand-off lives here.** Ticking "register this device"
* turns the form submission into a registration ceremony, and the TOTP check is
* what authorises it. That check — and the nonce check — already happen here, so
* a ceremony started anywhere earlier would mean validating the nonce somewhere
* new and risking spending it twice.
*/
final readonly class LoginManager implements LoginInterface
{
use GetTotpTrait;
use MakeNonceTrait;
use StringTrait;
public function __construct(
private BackupCodeInterface $backupCodeManager,
private SessionIssuerInterface $sessionIssuer,
private PasskeyInterface $passkeys,
) {
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function checkToken(Payload $payload, Request $request): ?Response
{
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
if (Scope::Ip === $payload->scope && !$this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie;
}
if (!$this->getTotp()->verify($payload->token, null, 1)
&& !$this->backupCodeManager->verifyAndConsume($payload->token)
) {
return null;
}
/* token is correct (TOTP or Backup) */
/* if server nonce is found and is valid */
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
if (!$nonceItem->isHit() || !$nonceItem->get()) {
return null;
}
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* the code and the nonce are both good from here on */
if ($payload->register && $payload->json) {
return $this->startRegistration($payload);
}
return $this->sessionIssuer->issue(
$payload->id,
$payload->scope,
$request,
$payload->json,
);
}
/**
* The registration hand-off: authorisation is already proven, so this issues
* the ceremony options back to the page instead of a session.
*
* `SessionIssuer` is deliberately not involved — the session is granted only
* once the new credential has actually been verified, at `register-finish`.
*
* **JSON only.** The checkbox is submitted through the same `X-Preauth` AJAX
* path as an ordinary login, so a failed attempt comes back as JSON carrying
* a fresh nonce. On the plain form-post path it would be HTML, the script's
* `response.json()` would throw, and — worse — the fresh nonce would be lost,
* so the user's retry would fail against a nonce that had already been spent.
* A non-JSON submission is therefore treated as an ordinary login; WebAuthn
* needs scripting regardless, so it is the checkbox that is the enhancement
* here, not the underlying login.
*/
private function startRegistration(Payload $payload): ?Response
{
try {
$payloadOut = $this->passkeys->beginRegistration($payload->id);
} catch (Throwable) {
/* a ceremony that cannot start must not become a 500 on the login
* page; falling through to the caller's failure path is the same
* treatment a wrong code gets */
return null;
}
$response = new Response(
(string) json_encode(['register' => $payloadOut]),
Response::HTTP_OK,
['Content-Type' => 'application/json'],
);
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
return $response;
}
}