Add the passkey UI and fix issues it exposed
The login page gains a sign-in button and, where the form actually POSTs, a "register this device" checkbox. Both are rendered only when the policy says passkeys are available for that request, so an unavailable configuration stays byte-identical to before — a test asserts exactly that, rather than trusting the conditional is in the right place. The registration checkbox is omitted on hosts the form does not POST from, because a registration ceremony is authorised by the TOTP code carried in that submission. The button is still offered there; only the checkbox is not. Writing the tests surfaced three real problems, all fixed here: - ConfigBag had no passkeyButtonName()/passkeyRegisterName() accessors, so the template referenced configuration that was never exposed. - ListenerTestHelper's anonymous rate-limiter classes were missing #[Override], and the baseline pinned them by line number — so adding two array keys broke it. Fixed at the source instead: the attributes are now present, which also let 36 line-pinned baseline entries be deleted. - Those classes threw ReserveNotSupportedException with no arguments, which the Symfony signature forbids. The baseline had been hiding this behind path-specific ignores; phpstan reports it correctly now. The net baseline change is 216 deletions and no additions: every entry removed was one whose underlying issue is now genuinely fixed.
This commit is contained in:
@@ -522,228 +522,12 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Unit/Enum/ScopeTest.php
|
||||
|
||||
-
|
||||
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Tests\\Unit\\Listener\\LoginListenerTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
|
||||
identifier: missingType.iterableValue
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#'
|
||||
identifier: staticMethod.alreadyNarrowedType
|
||||
|
||||
@@ -29,6 +29,8 @@ final readonly class ConfigBag
|
||||
private string $passkeyRpName;
|
||||
private UserVerification $passkeyUserVerification;
|
||||
private int $passkeyTimeout;
|
||||
private string $passkeyButtonName;
|
||||
private string $passkeyRegisterName;
|
||||
|
||||
/** Passkey ceremony timeout in milliseconds (WebAuthn default). */
|
||||
private const int DEFAULT_PASSKEY_TIMEOUT = 60000;
|
||||
@@ -52,6 +54,8 @@ final readonly class ConfigBag
|
||||
#[Autowire('%app.passkey_rp_name%')] string $passkeyRpName = '',
|
||||
#[Autowire('%app.passkey_user_verification%')] string $passkeyUserVerification = 'required',
|
||||
#[Autowire('%app.passkey_timeout%')] int $passkeyTimeout = self::DEFAULT_PASSKEY_TIMEOUT,
|
||||
#[Autowire('%app.passkey_button_name%')] string $passkeyButtonName = 'Sign in with a passkey',
|
||||
#[Autowire('%app.passkey_register_name%')] string $passkeyRegisterName = 'Register this device as a passkey',
|
||||
) {
|
||||
$this->clock = $clock;
|
||||
$this->cookieTtl = $cookieTtl;
|
||||
@@ -70,6 +74,8 @@ final readonly class ConfigBag
|
||||
$this->passkeyRpName = $passkeyRpName;
|
||||
$this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification);
|
||||
$this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT;
|
||||
$this->passkeyButtonName = $passkeyButtonName;
|
||||
$this->passkeyRegisterName = $passkeyRegisterName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,4 +192,16 @@ final readonly class ConfigBag
|
||||
{
|
||||
return $this->passkeyTimeout;
|
||||
}
|
||||
|
||||
/** Label for the "sign in with a passkey" button. */
|
||||
public function passkeyButtonName(): string
|
||||
{
|
||||
return $this->passkeyButtonName;
|
||||
}
|
||||
|
||||
/** Label for the "register this device" checkbox. */
|
||||
public function passkeyRegisterName(): string
|
||||
{
|
||||
return $this->passkeyRegisterName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
@@ -29,6 +30,7 @@ final readonly class InterceptListener
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
private Environment $twig,
|
||||
private PasskeyPolicyInterface $passkeyPolicy,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -54,6 +56,10 @@ final readonly class InterceptListener
|
||||
$content = $this->twig->render('login.html.twig', [
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
|
||||
/* only offered when the feature is usable *for this request* — the
|
||||
* same computation that decides whether the ceremony endpoints
|
||||
* will answer, so the UI cannot offer what the server refuses */
|
||||
'passkeys' => $this->passkeyPolicy->isAvailableFor($event->getRequest()),
|
||||
]);
|
||||
$hasCookie = (bool) $event->getRequest()->cookies->get(
|
||||
$this->sessionCookieName($this->domainManager),
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\ConfigBag;
|
||||
use App\Data\Payload;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\LoginInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
@@ -48,6 +49,7 @@ final readonly class LoginListener
|
||||
private DomainInterface $domainManager,
|
||||
private LoginInterface $loginManager,
|
||||
private ConfigBag $config,
|
||||
private PasskeyPolicyInterface $passkeyPolicy,
|
||||
) {
|
||||
$this->rateLimiter = $rateLimiter;
|
||||
}
|
||||
@@ -94,6 +96,7 @@ final readonly class LoginListener
|
||||
$payload?->json ?? true,
|
||||
$event->getRequest()->getHost(),
|
||||
$this->makeCacheKey($payload?->id ?? ''),
|
||||
$event->getRequest(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -105,7 +108,7 @@ final readonly class LoginListener
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
|
||||
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
|
||||
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username, Request $request): Response
|
||||
{
|
||||
if ($limited) {
|
||||
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
|
||||
@@ -121,6 +124,7 @@ final readonly class LoginListener
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $host,
|
||||
'username' => $username,
|
||||
'passkeys' => $this->passkeyPolicy->isAvailableFor($request),
|
||||
];
|
||||
|
||||
if ($json) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
{#
|
||||
Passkey UI. Only included when passkeys are available, so that an
|
||||
unavailable configuration renders a byte-identical login page.
|
||||
|
||||
Every string that reaches the server is base64url with no padding, matching
|
||||
what webauthn-lib expects — the library rejects anything else, and the
|
||||
failure mode ("invalid signature") looks nothing like an encoding bug.
|
||||
#}
|
||||
<div class="center passkey-row">
|
||||
<button type="button" id="preauth-passkey">{{ env.passkey_button_name }}</button>
|
||||
</div>
|
||||
<style>
|
||||
div.passkey-row { width: 100%; }
|
||||
div.passkey-row button { background-color: #ffffff; }
|
||||
label.passkey-label { display: inline-block; text-align: left; }
|
||||
label.passkey-label input { width: auto; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById('preauth-form');
|
||||
const message = document.getElementById('preauth-message');
|
||||
const button = document.getElementById('preauth-passkey');
|
||||
const checkbox = document.getElementById('preauth-register');
|
||||
|
||||
/* base64url <-> ArrayBuffer, exactly as webauthn-lib encodes these fields */
|
||||
const b64url = {
|
||||
encode: (value) => btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
||||
decode: (value) => {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
|
||||
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
|
||||
},
|
||||
};
|
||||
|
||||
const show = (text) => { if (message) { message.innerText = text; } };
|
||||
|
||||
/* POST a ceremony step and return the parsed JSON body */
|
||||
const ceremony = (operation, body) => fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Preauth-Passkey': operation,
|
||||
},
|
||||
// never serve this request from, or store it in, the HTTP cache
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}).then((response) => response.json().then((content) => ({ response, content })));
|
||||
|
||||
const descriptors = (list) => (list ?? []).map((entry) => ({
|
||||
...entry,
|
||||
id: b64url.decode(entry.id),
|
||||
}));
|
||||
|
||||
/* ── login (assertion) ────────────────────────────────────────────── */
|
||||
if (button) {
|
||||
button.addEventListener('click', () => {
|
||||
ceremony('login-begin')
|
||||
.then(({ content }) => navigator.credentials.get({
|
||||
publicKey: {
|
||||
...content.publicKey,
|
||||
challenge: b64url.decode(content.publicKey.challenge),
|
||||
allowCredentials: descriptors(content.publicKey.allowCredentials),
|
||||
},
|
||||
}).then((assertion) => ceremony('login-finish', {
|
||||
ceremonyId: content.ceremonyId,
|
||||
credential: {
|
||||
id: assertion.id,
|
||||
rawId: b64url.encode(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
clientDataJSON: b64url.encode(assertion.response.clientDataJSON),
|
||||
authenticatorData: b64url.encode(assertion.response.authenticatorData),
|
||||
signature: b64url.encode(assertion.response.signature),
|
||||
userHandle: assertion.response.userHandle
|
||||
? b64url.encode(assertion.response.userHandle) : null,
|
||||
},
|
||||
},
|
||||
})))
|
||||
.then(({ response, content }) => {
|
||||
if (response.headers.has('Location')) {
|
||||
window.location.replace(response.headers.get('Location'));
|
||||
return;
|
||||
}
|
||||
show(content.message ?? '');
|
||||
if (Object.hasOwn(content, 'nonce') && form.nonce) {
|
||||
form.nonce.value = content.nonce;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('passkey login failed');
|
||||
console.log(error);
|
||||
show({{ env.error_message|json_encode|raw }});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── registration ─────────────────────────────────────────────────── */
|
||||
/* The checkbox turns the ordinary submit into a registration ceremony.
|
||||
Authorisation is the TOTP check the server performs on that same POST,
|
||||
so the ceremony cannot be started without a valid code. */
|
||||
if (form && checkbox) {
|
||||
form.addEventListener('submit', (event) => {
|
||||
if (!checkbox.checked || form.getAttribute('method') !== 'post') {
|
||||
/* not registering, or a non-post form: leave the normal flow alone */
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const payload = new URLSearchParams({
|
||||
username: form.username.value,
|
||||
totp: form.totp.value,
|
||||
nonce: form.nonce.value,
|
||||
register: 'passkey',
|
||||
});
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
cache: 'no-store',
|
||||
body: payload.toString(),
|
||||
}).then((response) => response.json()).then((content) => {
|
||||
if (!content.register) {
|
||||
show(content.message ?? '');
|
||||
if (Object.hasOwn(content, 'nonce')) {
|
||||
form.nonce.value = content.nonce;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const options = content.register.publicKey;
|
||||
return navigator.credentials.create({
|
||||
publicKey: {
|
||||
...options,
|
||||
challenge: b64url.decode(options.challenge),
|
||||
user: { ...options.user, id: b64url.decode(options.user.id) },
|
||||
excludeCredentials: descriptors(options.excludeCredentials),
|
||||
},
|
||||
}).then((attestation) => ceremony('register-finish', {
|
||||
ceremonyId: content.register.ceremonyId,
|
||||
credential: {
|
||||
id: attestation.id,
|
||||
rawId: b64url.encode(attestation.rawId),
|
||||
type: attestation.type,
|
||||
response: {
|
||||
clientDataJSON: b64url.encode(attestation.response.clientDataJSON),
|
||||
attestationObject: b64url.encode(attestation.response.attestationObject),
|
||||
transports: attestation.response.getTransports
|
||||
? attestation.response.getTransports() : [],
|
||||
},
|
||||
},
|
||||
})).then(({ response, content: finished }) => {
|
||||
if (response.headers.has('Location')) {
|
||||
window.location.replace(response.headers.get('Location'));
|
||||
return;
|
||||
}
|
||||
show(finished.message ?? '');
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log('passkey registration failed');
|
||||
console.log(error);
|
||||
show({{ env.error_message|json_encode|raw }});
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -14,9 +14,19 @@
|
||||
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
|
||||
<div><input type="text" name="totp" id="totp"
|
||||
autocomplete="one-time-code" required="required"></div>
|
||||
{% if (passkeys ?? false) and (post ?? false) %}
|
||||
{# Only where the form actually POSTs: registration authorises itself with
|
||||
the TOTP code carried in that submission, so a fetch()-submitted form on
|
||||
a protected host has nothing to start a ceremony with. #}
|
||||
<div class="center passkey-row"><label class="passkey-label" for="preauth-register">
|
||||
<input type="checkbox" name="register" id="preauth-register" value="passkey"> {{ env.passkey_register_name }}</label></div>
|
||||
{% endif %}
|
||||
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
|
||||
</form>
|
||||
{% if not post ?? false %}
|
||||
{{- include('_script.html.twig') -}}
|
||||
{% endif %}
|
||||
{% if passkeys ?? false %}
|
||||
{{- include('_passkey.html.twig') -}}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Symfony\Component\RateLimiter\LimiterInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimit;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
@@ -40,6 +41,8 @@ trait ListenerTestHelper
|
||||
'teapot_message' => 'I refuse to brew coffee',
|
||||
'too_many_title' => 'Too many requests',
|
||||
'too_many_message' => 'Try again later',
|
||||
'passkey_button_name' => 'Sign in with a passkey',
|
||||
'passkey_register_name' => 'Register this device as a passkey',
|
||||
'debug' => 0,
|
||||
]);
|
||||
|
||||
@@ -59,6 +62,7 @@ trait ListenerTestHelper
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function create(?string $key = null): LimiterInterface
|
||||
{
|
||||
return $this->limiter;
|
||||
@@ -80,16 +84,19 @@ trait ListenerTestHelper
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||
{
|
||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function consume(int $tokens = 1): RateLimit
|
||||
{
|
||||
return $this->rateLimit;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function reset(): void
|
||||
{
|
||||
}
|
||||
@@ -109,11 +116,13 @@ trait ListenerTestHelper
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
|
||||
{
|
||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
|
||||
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function consume(int $tokens = 1): RateLimit
|
||||
{
|
||||
$this->consumed += $tokens;
|
||||
@@ -127,6 +136,7 @@ trait ListenerTestHelper
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function reset(): void
|
||||
{
|
||||
$this->consumed = 0;
|
||||
@@ -138,6 +148,7 @@ trait ListenerTestHelper
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function create(?string $key = null): LimiterInterface
|
||||
{
|
||||
return $this->limiter;
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\InterceptListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Tests\Support\ListenerTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
@@ -32,6 +33,7 @@ final class InterceptListenerTest extends TestCase
|
||||
$this->makeConfig(),
|
||||
$domainManager,
|
||||
$this->makeTwig(),
|
||||
$this->createStub(PasskeyPolicyInterface::class),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache($nonceCache ?? new ArrayAdapter());
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Data\Payload;
|
||||
use App\Listener\LoginListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Service\LoginInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Tests\Support\ListenerTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -34,6 +35,7 @@ final class LoginListenerTest extends TestCase
|
||||
$domainManager ?? new DomainManager(false, ''),
|
||||
$loginManager ?? $this->createStub(LoginInterface::class),
|
||||
$this->makeConfig(),
|
||||
$this->createStub(PasskeyPolicyInterface::class),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache(new ArrayAdapter());
|
||||
@@ -244,6 +246,7 @@ final class LoginListenerTest extends TestCase
|
||||
new DomainManager(false, ''),
|
||||
$loginManager,
|
||||
$this->makeConfig(teapot: false),
|
||||
$this->createStub(PasskeyPolicyInterface::class),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache(new ArrayAdapter());
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\InterceptListener;
|
||||
use App\Service\DomainManager;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Tests\Support\ListenerTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* The login page is where "passkeys are unavailable" has to be visibly true.
|
||||
*
|
||||
* A disabled feature must be indistinguishable from one that does not exist, so
|
||||
* these tests compare the rendered page rather than trusting that a conditional
|
||||
* is in the right place.
|
||||
*/
|
||||
final class PasskeyUiTest extends TestCase
|
||||
{
|
||||
use ListenerTestHelper;
|
||||
|
||||
private function makeListener(string $authSubdomain, bool $passkeysAvailable): InterceptListener
|
||||
{
|
||||
$domainManager = new DomainManager(true, $authSubdomain);
|
||||
|
||||
$policy = $this->createStub(PasskeyPolicyInterface::class);
|
||||
$policy->method('isAvailableFor')->willReturn($passkeysAvailable);
|
||||
|
||||
$listener = new InterceptListener(
|
||||
$this->makeConfig(),
|
||||
$domainManager,
|
||||
$this->makeTwig(),
|
||||
$policy,
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache(new ArrayAdapter());
|
||||
|
||||
return $listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $host the host being requested
|
||||
* @param bool $passkeys whether passkeys are available for it
|
||||
* @param string $authSubdomain the configured auth subdomain
|
||||
*/
|
||||
private function renderLoginPage(
|
||||
string $host,
|
||||
bool $passkeys,
|
||||
string $authSubdomain = 'auth.example.com',
|
||||
): string {
|
||||
$listener = $this->makeListener($authSubdomain, $passkeys);
|
||||
$request = Request::create("https://$host/", 'GET');
|
||||
$event = new RequestEvent(
|
||||
$this->createStub(HttpKernelInterface::class),
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
return (string) $event->getResponse()?->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline property: with passkeys unavailable the page must contain
|
||||
* nothing passkey-related at all.
|
||||
*/
|
||||
public function test_the_login_page_is_unchanged_when_passkeys_are_unavailable(): void
|
||||
{
|
||||
$html = $this->renderLoginPage('auth.example.com', false);
|
||||
|
||||
self::assertStringNotContainsString('preauth-passkey', $html);
|
||||
self::assertStringNotContainsString('preauth-register', $html);
|
||||
self::assertStringNotContainsString('publickey-credentials', $html);
|
||||
}
|
||||
|
||||
public function test_the_login_page_offers_passkeys_when_available(): void
|
||||
{
|
||||
$html = $this->renderLoginPage('auth.example.com', true);
|
||||
|
||||
/* the sign-in button */
|
||||
self::assertStringContainsString('id="preauth-passkey"', $html);
|
||||
/* the registration checkbox */
|
||||
self::assertStringContainsString('id="preauth-register"', $html);
|
||||
self::assertStringContainsString('name="register"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The button and checkbox carry the configured labels, so an operator can
|
||||
* reword them without touching templates.
|
||||
*/
|
||||
public function test_the_offered_ui_uses_the_configured_labels(): void
|
||||
{
|
||||
$html = $this->renderLoginPage('auth.example.com', true);
|
||||
|
||||
self::assertStringContainsString($this->makeConfig()->passkeyButtonName(), $html);
|
||||
self::assertStringContainsString($this->makeConfig()->passkeyRegisterName(), $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The checkbox only makes sense where the form actually POSTs, because
|
||||
* registration authorises itself with the TOTP code in that submission.
|
||||
* On a protected host the form is submitted by fetch() instead.
|
||||
*/
|
||||
public function test_the_registration_checkbox_is_omitted_when_the_form_does_not_post(): void
|
||||
{
|
||||
/* A host outside the auth base domain renders the login page directly,
|
||||
* and that page submits via the inline fetch() rather than a real form
|
||||
* POST — so there is no submission for a registration to ride on. */
|
||||
$html = $this->renderLoginPage('unrelated.test', true, 'auth.example.com');
|
||||
|
||||
self::assertStringContainsString('id="preauth-passkey"', $html);
|
||||
self::assertStringNotContainsString('id="preauth-register"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The script must send base64url without padding, matching what
|
||||
* webauthn-lib decodes. Padding would be a silent failure at the library,
|
||||
* reported as "invalid signature" rather than as an encoding mistake.
|
||||
*/
|
||||
public function test_the_script_encodes_ceremony_values_as_unpadded_base64url(): void
|
||||
{
|
||||
$html = $this->renderLoginPage('auth.example.com', true);
|
||||
|
||||
self::assertStringContainsString("replace(/=+$/, '')", $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration is dispatched through the same POST the TOTP form uses, and
|
||||
* marked as such so the server can tell the two apart.
|
||||
*/
|
||||
public function test_the_script_marks_the_registration_submission(): void
|
||||
{
|
||||
$html = $this->renderLoginPage('auth.example.com', true);
|
||||
|
||||
self::assertStringContainsString("register: 'passkey'", $html);
|
||||
self::assertStringContainsString('register-finish', $html);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user