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.
128 lines
4.5 KiB
PHP
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;
|
|
}
|
|
}
|