Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1335c31d4e | ||
|
|
9523accd23 | ||
|
|
6bbfd44e7d | ||
|
|
ffe6870231 | ||
|
|
11903bf746 | ||
|
|
69ee5e99aa |
@@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased] — v1.1
|
||||
|
||||
### Added
|
||||
- **Passkey authentication (WebAuthn)** — Registered passkeys can replace the
|
||||
TOTP code for everyday logins. Registration itself still requires a valid
|
||||
code, so a passkey can never be created without already holding the secret.
|
||||
- New dependency: `web-auth/webauthn-lib` `^5.3` (resolved to 5.3.9);
|
||||
`composer audit` reports no advisories.
|
||||
- **Requires central auth** (`SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`) so
|
||||
there is one relying party for the whole domain, and **requires HTTPS in
|
||||
every environment, development included**. Enabling it in a configuration
|
||||
that cannot work fails at container start rather than in a browser.
|
||||
- Registration is a checkbox on the login form; login is a button. Passive
|
||||
keys and OS pickers work normally, with the code as a fallback.
|
||||
- New `PasskeyListener` (priority 70) — after the rate-limit gate so a
|
||||
blocked IP never reaches a ceremony, and before `LoginListener` so a
|
||||
ceremony request is not misfiled as a failed login.
|
||||
- New env vars: `PASSKEY_ENABLED`, `PASSKEY_RP_NAME`,
|
||||
`PASSKEY_USER_VERIFICATION`, `PASSKEY_TIMEOUT`, `PASSKEY_BUTTON_NAME`,
|
||||
`PASSKEY_REGISTER_NAME`, `PASSKEY_BEGIN_BURST_COUNT`,
|
||||
`PASSKEY_BEGIN_BURST_TIME`.
|
||||
- New `docs/examples/Caddyfile` section describing local development over
|
||||
real TLS, because there is deliberately no `http://` exemption.
|
||||
- **Public rate-limited access** — Select paths can now be made publicly
|
||||
accessible without TOTP authentication, with separate per-IP rate limiting.
|
||||
This is useful for exposing public content (e.g., public Gitea repositories)
|
||||
@@ -25,7 +45,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- New `PublicPathMatcher` service for path pattern matching.
|
||||
- New `PublicAccessListener` (priority 84) in the request pipeline.
|
||||
|
||||
### Security
|
||||
- **Passkey ceremonies** — The challenge is issued and stored server-side and
|
||||
the client's copy is never trusted; it is single-use, deleted before
|
||||
verification so a failed or replayed attempt cannot be retried against the
|
||||
same challenge. Only the derived `https://{AUTH_SUBDOMAIN}` origin is ever
|
||||
accepted, and an unknown credential produces the same response as a wrong
|
||||
code so the endpoint cannot be used for enumeration.
|
||||
|
||||
### Changed
|
||||
- **Session issuing extracted into `SessionIssuer`** — `LoginManager`
|
||||
previously built the session cookie itself. Both login paths now share one
|
||||
implementation, so a passkey login and a code login set an identical cookie;
|
||||
two copies would have drifted, most likely in cookie attributes, where the
|
||||
difference is invisible until it breaks in a browser.
|
||||
- **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to
|
||||
`8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean
|
||||
(test suite runs with `failOnDeprecation`), so the major-version jump
|
||||
|
||||
@@ -8,6 +8,60 @@ This document was originally prepared as a design review. Items that have been a
|
||||
|
||||
---
|
||||
|
||||
## 0. Passkey implementation notes
|
||||
|
||||
Four decisions are worth recording because the reasoning is not obvious from the
|
||||
code, and each looks like an odd choice without it.
|
||||
|
||||
### 0.1 The signature counter is checked leniently, against the library's default
|
||||
|
||||
`webauthn-lib`'s default checker requires a *strictly increasing* counter. That
|
||||
is wrong for the passkeys this feature targets: a synchronised passkey reports a
|
||||
constant `0` forever, so the default rejects a brand-new credential on its
|
||||
**first** login. Measured against the installed version: stored `0`, reported
|
||||
`0` → `CounterException`.
|
||||
|
||||
The failure mode is what makes this worth a note. It cannot happen in a unit
|
||||
test that increments the counter — only on real hardware, and only for the most
|
||||
common kind of passkey. `PasskeyCounterChecker` therefore accepts equal-or-greater
|
||||
and rejects only a counter that moves *backwards*. Clone detection is explicitly
|
||||
not claimed as a property of this feature.
|
||||
|
||||
### 0.2 The ceremony replies are the only browser-facing 2xx
|
||||
|
||||
`SecurityHeadersListener` applies `no-store` to non-2xx responses only, on the
|
||||
assumption that a 2xx is consumed by the proxy's `forward_auth` check. That
|
||||
assumption is false for a ceremony reply: the auth subdomain is `reverse_proxy`-ed
|
||||
with no `forward_auth` in front of it, so the JSON goes straight to the browser.
|
||||
Left alone it would be cacheable, and a browser could replay a stale challenge.
|
||||
|
||||
The producer marks the response (`PasskeyListener` or `LoginManager`) and the
|
||||
caching policy lives in one place that consumes the marker, rather than being
|
||||
duplicated at each site that happens to return 2xx.
|
||||
|
||||
### 0.3 Registration is a checkbox on the login form, not an endpoint
|
||||
|
||||
A separate `register-begin` endpoint was the first design and would have been a
|
||||
vulnerability: it hands out a challenge without proving anything. The TOTP check
|
||||
is what authorises registration, and that check happens inside `LoginManager` as
|
||||
part of an ordinary login submission — so `LoginManager` is where the ceremony
|
||||
starts.
|
||||
|
||||
There is no session cookie to check at that point either, which makes the point
|
||||
neatly: the whole flow is what *produces* the session, so anything gated on one
|
||||
cannot be part of it. The capability at `register-finish` is the single-use
|
||||
ceremony id, issued server-side and bound to the identity that passed the check.
|
||||
|
||||
### 0.4 Both login paths share one session-issuing implementation
|
||||
|
||||
`SessionIssuer` was extracted from `LoginManager` when the passkey ceremony
|
||||
needed the same behaviour. Two implementations would have drifted, and the most
|
||||
likely place to drift is cookie attributes — where a difference is invisible
|
||||
until it breaks in a browser, on one path only. A functional test compares the
|
||||
cookies the two paths produce, field by field.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security
|
||||
|
||||
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
|
||||
|
||||
+26
-31
@@ -295,6 +295,9 @@ the management surface is incomplete.
|
||||
|
||||
### Phase 2c — Passkey Authentication
|
||||
|
||||
**Status: complete.** See `docs/passkey-auth-subdomain-plan.md` for the full
|
||||
plan, the evidence behind each decision, and the deviations noted below.
|
||||
|
||||
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
|
||||
authentication method alongside TOTP and backup codes.
|
||||
|
||||
@@ -305,39 +308,31 @@ codes. For a pre-auth gate that friends and family use, passkeys would
|
||||
be a major UX improvement — especially for non-technical users who
|
||||
struggle with TOTP apps.
|
||||
|
||||
**Design considerations:**
|
||||
**What was built**, and how it differs from the sketch above:
|
||||
|
||||
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
|
||||
secret shared with all devices), each device registers its own
|
||||
passkey. This is actually better for a family-use gate — you can
|
||||
register mom's phone separately from dad's laptop.
|
||||
- **A Symfony bundle was not used**, only `web-auth/webauthn-lib`. The bundle
|
||||
brings a database-backed credential repository and a controller setup that do
|
||||
not fit a no-database, listener-only application; the library alone is a
|
||||
clean fit and its types are confined to `PasskeyManager` and
|
||||
`PasskeyCeremonyFactory` so a major-version rename touches two files.
|
||||
- **Registration happens in the browser, not a console command.** The checkbox
|
||||
on the login form is authorised by the TOTP code in the same submission, so
|
||||
it needs no separate token and no CLI. This also settles the "how does the
|
||||
identity get specified" question: it is the identity that just authenticated.
|
||||
- **Central auth is a hard prerequisite.** A passkey is scoped to one relying
|
||||
party, so passkeys require `SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`; the RP ID
|
||||
is always that base domain. Without it the feature stays off, rather than
|
||||
quietly scoping credentials to a single host.
|
||||
- **HTTPS is required with no exemption**, development included, since an
|
||||
`http://` escape hatch is how the same weakness reaches production.
|
||||
- **Failed attempts share the TOTP rate-limit budget**, so passkeys cannot be
|
||||
used to sidestep a lockout.
|
||||
- **Attestation is `none`**, measured rather than assumed — see SECURITY.md and
|
||||
plan §2.3.
|
||||
|
||||
- WebAuthn requires a **challenge-response flow**:
|
||||
1. Client requests a challenge (preauth generates and stores a
|
||||
challenge nonce, similar to the existing nonce system)
|
||||
2. Browser prompts for biometric/PIN, creates a signed assertion
|
||||
3. Server verifies the assertion against the registered credential
|
||||
|
||||
- This is a **two-step flow** unlike TOTP's single-step, which means
|
||||
the login page JS and `LoginListener` need to handle an additional
|
||||
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
|
||||
is a good foundation — extend it with a "use passkey" button that
|
||||
initiates the `navigator.credentials.get()` flow.
|
||||
|
||||
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
|
||||
Symfony bundle available). Would add registration ceremony (console
|
||||
command or initial-setup flow to register a passkey).
|
||||
|
||||
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
||||
8.1 and FrankenPHP
|
||||
- [ ] Design passkey registration flow (console command? first-visit
|
||||
setup? separate registration endpoint?)
|
||||
- [ ] Implement challenge generation and storage (extend existing
|
||||
nonce/cache infrastructure)
|
||||
- [ ] Implement assertion verification in a new `PasskeyManager`
|
||||
service (implements a shared `AuthMethodInterface`?)
|
||||
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
|
||||
- [ ] Handle multiple registered passkeys (per-device)
|
||||
**Remaining work:** none for the feature itself. Discoverable-credential
|
||||
(usernameless) login is possible but not needed, since the login page already
|
||||
lists registered credentials.
|
||||
- [ ] Console command: `app:list-passkeys` — show registered devices
|
||||
- [ ] Console command: `app:remove-passkey` — revoke a passkey
|
||||
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
|
||||
|
||||
+53
@@ -51,6 +51,59 @@ calls it per request to decide whether a request may reach the upstream service.
|
||||
secrets belong in `.env.local` or `bin/console secrets:set`, read via
|
||||
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
|
||||
|
||||
### Passkeys (WebAuthn)
|
||||
|
||||
Off by default (`PASSKEY_ENABLED=0`). When on, the following hold:
|
||||
|
||||
- **Registration requires a valid TOTP code.** The checkbox rides on an
|
||||
ordinary login submission, and the code check in that same request is what
|
||||
authorises the ceremony. There is no enrolment token, no CLI path, and no way
|
||||
to create a credential without already holding the secret. The identity comes
|
||||
from the authenticated session, never from the request body.
|
||||
- **The challenge is server-authoritative and single-use.** It is generated and
|
||||
stored server-side; the client's copy is never trusted. The stored record is
|
||||
deleted *before* verification runs, so a failed or replayed attempt cannot be
|
||||
retried against the same challenge. Records live in the in-memory `nonceCache`
|
||||
with a 300-second TTL and deliberately do not survive a restart.
|
||||
- **Only the derived origin is accepted.** The allowed origin is always
|
||||
`https://{AUTH_SUBDOMAIN}`, computed from configuration and never from the
|
||||
request. `http://` is therefore rejected regardless of how the request
|
||||
arrived, and there is no setting that re-enables it. The library's deprecated
|
||||
`setSecuredRelyingPartyId()` escape hatch is not used, and development uses
|
||||
real TLS instead of an exemption.
|
||||
- **The RP ID is the base domain**, so a credential is scoped to every service
|
||||
on that domain. This is the intended behaviour and the reason central auth is
|
||||
a hard prerequisite: without a single shared domain there is no sane RP ID.
|
||||
- **Failures are indistinguishable.** An unknown credential, a bad signature
|
||||
and a wrong origin all produce the same response as a wrong TOTP code, so the
|
||||
endpoint cannot be used to enumerate credentials.
|
||||
- **Failures share the login rate-limit budget.** A failed ceremony costs the
|
||||
same token as a wrong code, and once the limit is reached every method is
|
||||
blocked. Passkeys cannot be used to sidestep a lockout, and the resource guard
|
||||
that bounds ceremony *starts* is deliberately separate, so a legitimate login
|
||||
never spends failure budget.
|
||||
- **The signature counter is not a security control.** Most passkeys —
|
||||
anything synchronised through a keychain — report a constant counter, so a
|
||||
counter-based clone check would lock users out of their own credentials.
|
||||
preauth accepts an unchanged counter and rejects only one that moves
|
||||
*backwards*, which is the only signal the value can carry. **Clone detection is
|
||||
deliberately not a property this feature claims.**
|
||||
- **Attestation is deliberately not requested** (`attestation: 'none'`).
|
||||
Attestation conveyance is only a preference a client may ignore, and the FIDO
|
||||
metadata service is bypassed both by the zero AAGUID that privacy-preserving
|
||||
passkeys already send and by self attestation — while still refusing
|
||||
legitimate authenticators newer than its cached blob. This was measured rather
|
||||
than assumed; see §2.3 of
|
||||
`docs/passkey-auth-subdomain-plan.md` for the evidence. **Revisit if** a
|
||||
deployment needs to prove which make and model of authenticator is enrolled,
|
||||
or if a policy (rather than a preference) requires attested keys — in which
|
||||
case the metadata service must be pinned and kept current, and the zero-AAGUID
|
||||
case decided explicitly rather than by omission.
|
||||
- **The ceremony replies are not cacheable.** They are the only 2xx this
|
||||
application returns straight to a browser (every other 2xx is consumed by the
|
||||
proxy's `forward_auth` check), so they carry the same anti-caching headers as
|
||||
the rest of the login flow.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: the application code in `src/`, the shipped `Caddyfile`, the
|
||||
|
||||
@@ -652,6 +652,10 @@ the `doctrine/deprecations` triggers).
|
||||
|
||||
## 8. Implementation order
|
||||
|
||||
**Status: complete.** All steps below are implemented and on
|
||||
`feat/passkey-auth-subdomain`. Two deviations from the order as written, both
|
||||
noted inline.
|
||||
|
||||
Each step is independently committable and leaves the suite green.
|
||||
|
||||
1. **Dependency** *(done on the spike branch)* — `composer require
|
||||
@@ -667,11 +671,29 @@ Each step is independently committable and leaves the suite green.
|
||||
5. **`PasskeyListener`** — priority 70, header dispatch, always terminate,
|
||||
no-store marker. Unit-test every branch incl. "post-shaped request must not
|
||||
reach `LoginListener`".
|
||||
> **Deviation 1 — done after step 6.** The listener needs the shared session
|
||||
> issuing that step 6 extracts, so the order had to be inverted.
|
||||
>
|
||||
> **Deviation 2 — three operations, not four.** `register-begin` is not a
|
||||
> listener operation, and the first implementation was wrong to make it one.
|
||||
> It would have handed out a challenge without proving anything; there is
|
||||
> also no session cookie to check at that point, since the whole flow is what
|
||||
> produces the session. The ceremony is started by `LoginManager`, after it
|
||||
> verifies the code and nonce. A test pins that the listener refuses the
|
||||
> operation.
|
||||
6. **Extract session issuing** from `LoginManager` so both paths share it —
|
||||
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
|
||||
before touching anything else (Q3.8).
|
||||
> Verified as intended: all 18 existing `LoginManagerTest` cases passed
|
||||
> unchanged, and a functional test now compares the two paths' cookies
|
||||
> field by field.
|
||||
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
|
||||
hand-off described in §3.1, `_passkey_register.html.twig`.
|
||||
> Note: the separate script template was not needed — both handlers share
|
||||
> helpers, so `_passkey.html.twig` holds them and `login.html.twig` stays a
|
||||
> single readable file. The checkbox is also **not** rendered where the form
|
||||
> does not POST, since registration authorises itself with the code carried
|
||||
> in that submission.
|
||||
8. **Login UI + CSP** — `_passkey.html.twig`, `SecurityHeadersListener`, extend
|
||||
`CacheControlFlowTest` and `SecurityHeadersListenerTest`.
|
||||
9. **Functional tests** with real crypto (§7.2).
|
||||
|
||||
+1
-217
@@ -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
|
||||
@@ -759,7 +543,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 17
|
||||
count: 20
|
||||
path: tests/Unit/Service/LoginManagerTest.php
|
||||
|
||||
-
|
||||
|
||||
@@ -152,6 +152,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
||||
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
||||
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
||||
|
||||
### Passkey Authentication
|
||||
|
||||
Passkeys (WebAuthn) can replace the TOTP code for everyday logins, while the
|
||||
code remains the way a new device is enrolled.
|
||||
|
||||
**Two prerequisites, both enforced.** The feature refuses to operate without
|
||||
them rather than degrading quietly:
|
||||
|
||||
1. **Central auth must be configured** (`SUBDOMAIN_REDIRECT=true` and a real
|
||||
`AUTH_SUBDOMAIN`). A passkey is scoped to one relying party, so there has to
|
||||
be a single shared domain for the whole family of services. Without it,
|
||||
passkeys are switched off — they would otherwise be scoped to a single host
|
||||
and confuse users with multiple, unrelated passkeys.
|
||||
2. **HTTPS is required, in development too.** There is no `http://localhost`
|
||||
exemption and no setting that re-enables one, because such an exemption is
|
||||
exactly how the same weakness ends up enabled in production. To exercise
|
||||
passkeys locally, see the TLS note in `docs/examples/Caddyfile`.
|
||||
|
||||
`localhost` therefore cannot be used for passkeys: it has no base domain, so
|
||||
central auth cannot be configured at all.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PASSKEY_ENABLED` | `0` | Master switch. Enabling it without the prerequisites above makes the container fail at startup, rather than offering a feature that cannot work. |
|
||||
| `PASSKEY_RP_NAME` | `TITLE` | Name shown in the authenticator prompt. |
|
||||
| `PASSKEY_USER_VERIFICATION` | `required` | `required`, `preferred`, or `discouraged`. An unrecognised value falls back to `required`, never to something weaker. |
|
||||
| `PASSKEY_TIMEOUT` | `60000` | Ceremony timeout in milliseconds. |
|
||||
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | Label for the sign-in button. |
|
||||
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | Label for the registration checkbox. |
|
||||
| `PASSKEY_BEGIN_BURST_COUNT` | `30` | Ceremonies one caller may start per window. A resource guard, not part of the login budget. |
|
||||
| `PASSKEY_BEGIN_BURST_TIME` | `60` | Window for the above, in seconds. |
|
||||
|
||||
**Registering a device.** Log in with your code as usual, tick *Register this
|
||||
device as a passkey*, and approve the prompt. The TOTP check in that same
|
||||
submission is what authorises the registration — there is no separate enrolment
|
||||
token and no CLI command, so a passkey cannot be created without already holding
|
||||
a valid code.
|
||||
|
||||
**Logging in.** Once registered, *Sign in with a passkey* signs you in with a
|
||||
fingerprint, face, or device PIN instead of typing a code. Failed passkey
|
||||
attempts count against the same rate-limit budget as wrong codes, so passkeys
|
||||
cannot be used to sidestep a lockout, and after the limit is reached *every*
|
||||
method is blocked equally.
|
||||
|
||||
> **On signature counters.** Many passkeys (anything synchronised through a
|
||||
> keychain) report a constant counter, so a counter-based clone check would lock
|
||||
> users out of their own credentials. Preauth accepts an unchanged counter and
|
||||
> rejects only one that moves *backwards*. Clone detection is deliberately not a
|
||||
> property this feature claims — see `SECURITY.md`.
|
||||
|
||||
### Public Rate-Limited Access
|
||||
|
||||
Preauth can provide rate-limited unauthenticated access to select public
|
||||
@@ -235,9 +285,17 @@ passes through a priority-ordered chain of listeners:
|
||||
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
||||
allows rate-limited unauthenticated access to matching paths.
|
||||
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
||||
5. **LoginListener** (priority 66) — Processes login attempts.
|
||||
6. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||
7. **SecurityHeadersListener** (response) — Adds security headers.
|
||||
5. **PasskeyListener** (priority 70) — WebAuthn ceremonies, when enabled.
|
||||
6. **LoginListener** (priority 66) — Processes login attempts.
|
||||
7. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||
8. **SecurityHeadersListener** (response) — Adds security headers.
|
||||
|
||||
`PasskeyListener` sits deliberately between the rate-limit gate and the login
|
||||
handler: **after** `RejectListener`, so a blocked IP never reaches a ceremony;
|
||||
and **before** `LoginListener`, because that listener treats any POST to the auth
|
||||
subdomain as a login attempt, and a ceremony request carries no code — it would
|
||||
otherwise be counted as a failed login and burn rate-limit budget on every
|
||||
legitimate passkey sign-in.
|
||||
|
||||
### Security Model
|
||||
|
||||
@@ -256,6 +314,13 @@ passes through a priority-ordered chain of listeners:
|
||||
Successful (2xx) responses are deliberately excluded — they are
|
||||
consumed by the proxy's `forward_auth` check and never reach the
|
||||
browser, so a protected service's own caching is not affected.
|
||||
Passkey ceremony replies are the one exception: they are the only 2xx
|
||||
this application returns straight to a browser, so they carry the same
|
||||
anti-caching headers.
|
||||
- **Passkeys**: registerable only after a valid TOTP code; challenge is
|
||||
server-issued and single-use; RP ID is always the base domain; only the
|
||||
derived `https://` origin is ever accepted; a failed attempt is
|
||||
indistinguishable from a wrong code and shares its rate-limit budget.
|
||||
|
||||
### Cache
|
||||
|
||||
|
||||
@@ -22,4 +22,18 @@ final class AppConstants
|
||||
* Also used for cache key truncation.
|
||||
*/
|
||||
public const int MAX_INPUT_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* Marks a response as WebAuthn ceremony output.
|
||||
*
|
||||
* A ceremony reply is the only 2xx this application returns straight to a
|
||||
* browser — every other 2xx is consumed by the reverse proxy's forward_auth
|
||||
* check. So it is the only one that needs the no-store treatment, and this
|
||||
* marker is how `SecurityHeadersListener` recognises it without the caching
|
||||
* policy being duplicated at each site that produces one.
|
||||
*
|
||||
* It lives here rather than on a listener because both `PasskeyListener`
|
||||
* (finish) and `LoginManager` (the registration hand-off) produce them.
|
||||
*/
|
||||
public const string PASSKEY_CEREMONY_MARKER = 'X-Preauth-Ceremony';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@ final class Payload
|
||||
public bool $json; /* should we return json (for the login page) */
|
||||
public Scope $scope; /* type of access being requested */
|
||||
|
||||
/**
|
||||
* The caller ticked "register this device as a passkey".
|
||||
*
|
||||
* Carried on the payload rather than handled by the listener, because the
|
||||
* TOTP check is what authorises registration — so the intent has to reach
|
||||
* `LoginManager`, which is where that check (and the nonce check) already
|
||||
* happens. Starting a ceremony any earlier would move nonce validation and
|
||||
* risk spending it twice.
|
||||
*/
|
||||
public bool $register = false;
|
||||
|
||||
public static function decode(string $base64url): ?self
|
||||
{
|
||||
/* convert the base64url into json string */
|
||||
@@ -42,6 +53,7 @@ final class Payload
|
||||
'id' => $input->get('username'),
|
||||
'nonce' => $input->get('nonce'),
|
||||
'token' => $input->get('totp'),
|
||||
'register' => $input->get('register'),
|
||||
'json' => false,
|
||||
]);
|
||||
}
|
||||
@@ -65,6 +77,7 @@ final class Payload
|
||||
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
$payload->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
$payload->json = ($data->json ?? true);
|
||||
$payload->register = (bool) ($data->register ?? false);
|
||||
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
|
||||
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,17 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\ConfigBag;
|
||||
use App\Enum\Scope;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Service\SessionIssuerInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
@@ -36,13 +35,21 @@ use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
* returns null and the request would be scored as a failed login — burning a
|
||||
* rate-limit token for every legitimate passkey login.
|
||||
*
|
||||
* **Three operations, not four.** `register-begin` is deliberately *absent*: a
|
||||
* registration ceremony may only be started after a valid TOTP code, which is
|
||||
* presented to `LoginManager` as part of the form submission. `LoginManager`
|
||||
* therefore starts that ceremony and returns its options with the login
|
||||
* response. Exposing `register-begin` here would be a way to obtain a challenge
|
||||
* **without proving anything**, which is the vulnerability rather than the
|
||||
* feature — there is no session cookie to check at that point either, since the
|
||||
* whole flow is what *produces* the session.
|
||||
*
|
||||
* **Every** request carrying the dispatch header gets a response, including
|
||||
* malformed ones. Falling through would let `InterceptListener` render HTML to a
|
||||
* `fetch()` caller.
|
||||
*/
|
||||
final readonly class PasskeyListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
@@ -55,16 +62,11 @@ final readonly class PasskeyListener
|
||||
*/
|
||||
public const string HEADER = 'X-Preauth-Passkey';
|
||||
|
||||
/** Marks a response as ceremony output, so the caching policy can see it. */
|
||||
public const string CEREMONY_MARKER = 'X-Preauth-Ceremony';
|
||||
public const string BEGIN_LOGIN = 'login-begin';
|
||||
|
||||
private const string BEGIN_LOGIN = 'login-begin';
|
||||
public const string FINISH_LOGIN = 'login-finish';
|
||||
|
||||
private const string FINISH_LOGIN = 'login-finish';
|
||||
|
||||
private const string BEGIN_REGISTER = 'register-begin';
|
||||
|
||||
private const string FINISH_REGISTER = 'register-finish';
|
||||
public const string FINISH_REGISTER = 'register-finish';
|
||||
|
||||
private RateLimiterFactoryInterface $beginLimiter;
|
||||
|
||||
@@ -73,7 +75,6 @@ final readonly class PasskeyListener
|
||||
public function __construct(
|
||||
#[Target('passkey_begin_burst')] RateLimiterFactoryInterface $beginLimiter,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $loginLimiter,
|
||||
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
|
||||
private PasskeyInterface $passkeys,
|
||||
private PasskeyPolicyInterface $policy,
|
||||
private SessionIssuerInterface $sessionIssuer,
|
||||
@@ -116,7 +117,6 @@ final readonly class PasskeyListener
|
||||
return $this->ceremonyResponse(match ($operation) {
|
||||
self::BEGIN_LOGIN => $this->beginLogin($request),
|
||||
self::FINISH_LOGIN => $this->finishLogin($request),
|
||||
self::BEGIN_REGISTER => $this->beginRegistration($request),
|
||||
self::FINISH_REGISTER => $this->finishRegistration($request),
|
||||
default => $this->error('Unknown passkey operation.', $request),
|
||||
});
|
||||
@@ -131,25 +131,6 @@ final readonly class PasskeyListener
|
||||
return $this->json($this->passkeys->beginLogin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration is only offered to someone who already authenticated: the
|
||||
* identity comes from the live session, never from the request body, so a
|
||||
* caller cannot register a passkey for an identity it does not hold.
|
||||
*/
|
||||
private function beginRegistration(Request $request): Response
|
||||
{
|
||||
$identity = $this->identityFromRequest($request);
|
||||
if (null === $identity) {
|
||||
return $this->error('Registration requires a completed login.', $request);
|
||||
}
|
||||
|
||||
if ($limit = $this->beginBurstExceeded($request)) {
|
||||
return $limit;
|
||||
}
|
||||
|
||||
return $this->json($this->passkeys->beginRegistration($identity));
|
||||
}
|
||||
|
||||
private function finishLogin(Request $request): Response
|
||||
{
|
||||
$credential = $this->passkeys->finishLogin($this->body($request));
|
||||
@@ -165,6 +146,12 @@ final readonly class PasskeyListener
|
||||
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony was authorised by the TOTP-gated hand-off in `LoginManager`,
|
||||
* so the capability here is the single-use `ceremonyId` itself: it is
|
||||
* server-issued, stored against the identity that passed the check, and
|
||||
* consumed on use.
|
||||
*/
|
||||
private function finishRegistration(Request $request): Response
|
||||
{
|
||||
$credential = $this->passkeys->finishRegistration($this->body($request));
|
||||
@@ -178,31 +165,6 @@ final readonly class PasskeyListener
|
||||
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity of an already-authenticated caller, for registration.
|
||||
*
|
||||
* Read from the live session cookie, so `register-begin` is reachable only by
|
||||
* someone who has just passed the TOTP check. Null when there is no session.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function identityFromRequest(Request $request): ?string
|
||||
{
|
||||
$cookie = $request->cookies->get($this->sessionCookieName($this->domainManager));
|
||||
if (!\is_string($cookie) || '' === $cookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = $this->sessionCache->getItem($this->makeCacheKey("cookie_$cookie"));
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$identity = $item->get();
|
||||
|
||||
return \is_string($identity) && '' !== $identity ? $identity : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds how many ceremonies one caller can start.
|
||||
*
|
||||
@@ -266,7 +228,7 @@ final readonly class PasskeyListener
|
||||
*/
|
||||
private function ceremonyResponse(Response $response): Response
|
||||
{
|
||||
$response->headers->set(self::CEREMONY_MARKER, '1');
|
||||
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
|
||||
$response->headers->set('Content-Type', 'application/json');
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
@@ -98,8 +99,8 @@ final readonly class SecurityHeadersListener
|
||||
return;
|
||||
}
|
||||
|
||||
if ($headers->has(PasskeyListener::CEREMONY_MARKER)) {
|
||||
$headers->remove(PasskeyListener::CEREMONY_MARKER);
|
||||
if ($headers->has(AppConstants::PASSKEY_CEREMONY_MARKER)) {
|
||||
$headers->remove(AppConstants::PASSKEY_CEREMONY_MARKER);
|
||||
$this->applyNoStore($headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\Trait\GetTotpTrait;
|
||||
@@ -13,14 +14,21 @@ 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, grants access.
|
||||
* Authenticates a TOTP code (or backup code) and, on success, either grants
|
||||
* access or starts a passkey registration.
|
||||
*
|
||||
* The "grant access" half now lives in {@see SessionIssuer} so the passkey
|
||||
* ceremony produces an identical response. This class keeps the part that is
|
||||
* genuinely specific to code-based login: verifying the code and enforcing the
|
||||
* single-use nonce.
|
||||
* 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
|
||||
{
|
||||
@@ -31,6 +39,7 @@ final readonly class LoginManager implements LoginInterface
|
||||
public function __construct(
|
||||
private BackupCodeInterface $backupCodeManager,
|
||||
private SessionIssuerInterface $sessionIssuer,
|
||||
private PasskeyInterface $passkeys,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -65,6 +74,12 @@ final readonly class LoginManager implements LoginInterface
|
||||
$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,
|
||||
@@ -72,4 +87,41 @@ final readonly class LoginManager implements LoginInterface
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +95,6 @@ final readonly class PasskeyCeremonyFactory
|
||||
);
|
||||
}
|
||||
|
||||
public function counterChecker(): PasskeyCounterChecker
|
||||
{
|
||||
return $this->counterChecker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony steps shared by both ceremonies.
|
||||
*
|
||||
|
||||
@@ -109,13 +109,16 @@ final readonly class PasskeyManager implements PasskeyInterface
|
||||
}
|
||||
|
||||
/* the credential id selects the record: an attacker cannot nominate a
|
||||
* different credential than the one they hold the key for */
|
||||
$stored = $this->credentials->find($publicKeyCredential->rawId);
|
||||
if (null === $stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
* different credential than the one they hold the key for.
|
||||
*
|
||||
* Inside the try: a cache failure must degrade to "this passkey is
|
||||
* unavailable", never to a 500 on the login page. */
|
||||
try {
|
||||
$stored = $this->credentials->find($publicKeyCredential->rawId);
|
||||
if (null === $stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$updated = $this->factory
|
||||
->requestCeremonyValidator($this->policy->allowedOrigins())
|
||||
->check(
|
||||
@@ -210,7 +213,15 @@ final readonly class PasskeyManager implements PasskeyInterface
|
||||
$this->labelFor($record),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$this->credentials->save($credential);
|
||||
|
||||
try {
|
||||
$this->credentials->save($credential);
|
||||
} catch (Throwable) {
|
||||
/* a store that cannot persist a credential must not report success:
|
||||
* the user would believe the passkey was registered and then find it
|
||||
* missing at the next login */
|
||||
return null;
|
||||
}
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
{#
|
||||
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 an ordinary submit into a registration ceremony.
|
||||
Authorisation is the TOTP check the server performs on that same
|
||||
submission, so a ceremony cannot be started without a valid code.
|
||||
|
||||
This goes through the same X-Preauth AJAX path as a normal login rather
|
||||
than a plain form post, so that failures come back as JSON with a fresh
|
||||
nonce — a form post would return HTML and lose the nonce, making the
|
||||
user's next attempt fail for a reason they could not see. */
|
||||
if (form && checkbox) {
|
||||
form.addEventListener('submit', (event) => {
|
||||
if (!checkbox.checked) {
|
||||
/* not registering: leave the normal submit path alone */
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.username.value?.trim() ?? '',
|
||||
token: form.totp.value?.trim() ?? '',
|
||||
nonce: form.nonce.value?.trim() ?? '',
|
||||
register: 'passkey',
|
||||
json: true,
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: { 'X-Preauth': data },
|
||||
cache: 'no-store',
|
||||
}).then((response) => response.json()).then((content) => {
|
||||
if (!content.register) {
|
||||
show(content.message ?? '');
|
||||
if (Object.hasOwn(content, 'nonce')) {
|
||||
form.nonce.value = content.nonce;
|
||||
form.totp.value = '';
|
||||
form.totp.focus();
|
||||
}
|
||||
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 %}
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\Tests\Support\PasskeyTestHelper;
|
||||
use OTPHP\TOTP;
|
||||
use Override;
|
||||
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The whole flow through the real HTTP kernel, with real cryptography.
|
||||
*
|
||||
* Nothing about the ceremony is stubbed: the registration builds a genuine CBOR
|
||||
* attestation object signed by a real P-256 key, and the login signs a real
|
||||
* assertion. So a pass here means the feature works, not that our mocks agree
|
||||
* with our code. What *is* simulated is only the browser's plumbing — the
|
||||
* `fetch()` calls become requests, which is exactly the seam worth testing.
|
||||
*
|
||||
* Passkeys are off in `.env.test` (most of the suite expects today's behaviour),
|
||||
* so this test turns them on for itself.
|
||||
*/
|
||||
final class PasskeyFlowTest extends WebTestCase
|
||||
{
|
||||
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
private const string IDENTITY = 'lyra';
|
||||
|
||||
/** The auth subdomain, which is also the only allowed ceremony origin. */
|
||||
private const string AUTH_HOST = 'auth.example.com';
|
||||
|
||||
/** The RP ID: the base domain, so credentials are shared across it. */
|
||||
private const string RP_ID = 'example.com';
|
||||
|
||||
private const string ORIGIN = 'https://auth.example.com';
|
||||
|
||||
private const string AUTH_COOKIE = '__Http-Domain-Preauth';
|
||||
|
||||
private ?PasskeyTestHelper $helper = null;
|
||||
|
||||
/** One kernel per test, as WebTestCase requires. */
|
||||
private ?KernelBrowser $client = null;
|
||||
|
||||
private ?string $credentialId = null;
|
||||
|
||||
/** @var array<string,string> */
|
||||
private static array $passkeyEnv = [
|
||||
'PASSKEY_ENABLED' => '1',
|
||||
'SUBDOMAIN_REDIRECT' => '1',
|
||||
'AUTH_SUBDOMAIN' => self::AUTH_HOST,
|
||||
];
|
||||
|
||||
/**
|
||||
* Turn passkeys on for this test only.
|
||||
*
|
||||
* Env placeholders resolve at runtime, so setting these before the kernel
|
||||
* boots is enough and no separate cache directory is needed.
|
||||
*/
|
||||
private function createPasskeyClient(): KernelBrowser
|
||||
{
|
||||
foreach (self::$passkeyEnv as $name => $value) {
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
|
||||
/* WebTestCase allows exactly one kernel per test, so a test needing a
|
||||
* second "visitor" gets this browser with a cleared cookie jar rather
|
||||
* than a new kernel. */
|
||||
if (null === $this->client) {
|
||||
$this->client = static::createClient();
|
||||
$this->client->disableReboot();
|
||||
}
|
||||
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same kernel, with no cookies — a fresh visitor.
|
||||
*
|
||||
* Needed because a granted session makes AcceptListener short-circuit at 200
|
||||
* before any ceremony listener runs, so a test that registers first and then
|
||||
* wants to exercise a ceremony must not carry that cookie.
|
||||
*/
|
||||
private function freshVisitor(): KernelBrowser
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
$client->getCookieJar()->clear();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (array_keys(self::$passkeyEnv) as $name) {
|
||||
unset($_ENV[$name], $_SERVER[$name]);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function helper(): PasskeyTestHelper
|
||||
{
|
||||
return $this->helper ??= new PasskeyTestHelper();
|
||||
}
|
||||
|
||||
private function credentialId(): string
|
||||
{
|
||||
return $this->credentialId ??= $this->helper()->credentialId();
|
||||
}
|
||||
|
||||
private function validTotpCode(): string
|
||||
{
|
||||
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The nonce issued with the login page, which the form must echo back.
|
||||
*/
|
||||
private function nonceFrom(KernelBrowser $client): string
|
||||
{
|
||||
$crawler = $client->request('GET', self::ORIGIN.'/');
|
||||
|
||||
return (string) $crawler->filter('input[name="nonce"]')->attr('value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1+2 of registration: submit the form with the checkbox ticked.
|
||||
*
|
||||
* @return array{publicKey: array<string,mixed>, ceremonyId: string}
|
||||
*/
|
||||
private function beginRegistration(KernelBrowser $client, string $nonce, string $totp = ''): array
|
||||
{
|
||||
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => self::IDENTITY,
|
||||
'token' => '' === $totp ? $this->validTotpCode() : $totp,
|
||||
'nonce' => $nonce,
|
||||
'register' => 'passkey',
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), (string) $response->getContent());
|
||||
|
||||
$content = json_decode((string) $response->getContent(), true);
|
||||
self::assertIsArray($content);
|
||||
self::assertArrayHasKey('register', $content);
|
||||
|
||||
return $content['register'];
|
||||
}
|
||||
|
||||
/**
|
||||
* An ordinary code login, returning the cookie it sets.
|
||||
*
|
||||
* Used to prove both login paths agree on the cookie; the passkey flow is
|
||||
* otherwise easy to break in a way that only shows up in a browser.
|
||||
*/
|
||||
private function codeLoginCookie(): \Symfony\Component\HttpFoundation\Cookie
|
||||
{
|
||||
$client = $this->freshVisitor();
|
||||
$nonce = $this->nonceFrom($client);
|
||||
|
||||
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => self::IDENTITY,
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
|
||||
|
||||
return $this->authCookieFrom($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* base64url-encode a payload, matching the client-side script.
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
private function encodePayload(array $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode((string) json_encode($data)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of registration: send the attestation the authenticator produced.
|
||||
*/
|
||||
private function finishRegistration(KernelBrowser $client, string $ceremonyId, string $challenge): Response
|
||||
{
|
||||
$credential = $this->helper()->registrationCredential(
|
||||
self::RP_ID,
|
||||
$challenge,
|
||||
self::ORIGIN,
|
||||
$this->credentialId(),
|
||||
);
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::ORIGIN.'/',
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'register-finish',
|
||||
],
|
||||
(string) json_encode(['ceremonyId' => $ceremonyId, 'credential' => $credential]),
|
||||
);
|
||||
|
||||
return $client->getResponse();
|
||||
}
|
||||
|
||||
/* ── registration ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The headline end-to-end property: a real registration is accepted and
|
||||
* grants a session.
|
||||
*/
|
||||
public function test_a_real_registration_grants_a_session(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
$nonce = $this->nonceFrom($client);
|
||||
|
||||
$started = $this->beginRegistration($client, $nonce);
|
||||
self::assertArrayHasKey('ceremonyId', $started);
|
||||
self::assertSame(self::RP_ID, $started['publicKey']['rp']['id']);
|
||||
|
||||
$response = $this->finishRegistration(
|
||||
$client,
|
||||
$started['ceremonyId'],
|
||||
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||
);
|
||||
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
|
||||
|
||||
/* the session cookie is domain-scoped so every subdomain accepts it */
|
||||
$cookie = $this->authCookieFrom($response);
|
||||
self::assertSame(self::AUTH_COOKIE, $cookie->getName());
|
||||
self::assertSame(self::RP_ID, $cookie->getDomain());
|
||||
self::assertTrue($cookie->isSecure());
|
||||
self::assertTrue($cookie->isHttpOnly());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration is authorised by the TOTP code, so a bad code must not start
|
||||
* a ceremony — and must not leave one behind to be finished later.
|
||||
*/
|
||||
public function test_registration_with_a_bad_code_starts_no_ceremony(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
$nonce = $this->nonceFrom($client);
|
||||
|
||||
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => self::IDENTITY,
|
||||
'token' => '000000',
|
||||
'nonce' => $nonce,
|
||||
'register' => 'passkey',
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
|
||||
$content = json_decode((string) $response->getContent(), true);
|
||||
self::assertIsArray($content);
|
||||
self::assertArrayNotHasKey('register', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* A spent nonce must be refused even with a valid code, or the ceremony
|
||||
* hand-off would be replayable.
|
||||
*/
|
||||
public function test_registration_with_a_spent_nonce_starts_no_ceremony(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
$nonce = $this->nonceFrom($client);
|
||||
|
||||
/* spend the nonce with a first successful login */
|
||||
$client->request('GET', self::ORIGIN.'/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => self::IDENTITY,
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $client->getResponse()->getStatusCode());
|
||||
|
||||
/* now try to reuse it for registration */
|
||||
$reuse = $this->freshVisitor();
|
||||
$reuse->request('GET', self::ORIGIN.'/', [], [], [
|
||||
'HTTP_X-Preauth' => $this->encodePayload([
|
||||
'id' => self::IDENTITY,
|
||||
'token' => $this->validTotpCode(),
|
||||
'nonce' => $nonce,
|
||||
'register' => 'passkey',
|
||||
'json' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $reuse->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── login ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The other half of the story: register once, then log in with the passkey
|
||||
* instead of a code — through the real validator, with a real signature.
|
||||
*/
|
||||
public function test_a_real_passkey_login_grants_a_session(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
|
||||
/* register first */
|
||||
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||
$registered = $this->finishRegistration(
|
||||
$client,
|
||||
$started['ceremonyId'],
|
||||
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||
);
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $registered->getStatusCode());
|
||||
|
||||
/* the stored record's counter is the one the registration used, and the
|
||||
* lenient policy accepts an equal or greater value */
|
||||
$counter = $this->helper()->counter() + 1;
|
||||
|
||||
/* drop the cookie the registration granted, or AcceptListener would
|
||||
* answer before the ceremony listener is reached */
|
||||
$client = $this->freshVisitor();
|
||||
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||
], '{}');
|
||||
|
||||
$begin = json_decode((string) $client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($begin);
|
||||
self::assertSame(self::RP_ID, $begin['publicKey']['rpId']);
|
||||
|
||||
/* the registered credential is offered to the authenticator */
|
||||
self::assertNotEmpty($begin['publicKey']['allowCredentials']);
|
||||
|
||||
$challenge = Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']);
|
||||
$assertion = $this->helper()->assertionCredential(
|
||||
self::RP_ID,
|
||||
$challenge,
|
||||
self::ORIGIN,
|
||||
$this->credentialId(),
|
||||
$counter,
|
||||
hash('sha256', self::IDENTITY, true),
|
||||
);
|
||||
|
||||
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-finish',
|
||||
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
|
||||
|
||||
/* the Remote-User header proves which identity was authenticated */
|
||||
self::assertSame(self::IDENTITY, $response->headers->get('Remote-User'));
|
||||
self::assertSame(self::AUTH_COOKIE, $this->authCookieFrom($response)->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* A replayed ceremony must fail: the challenge is consumed on first use, so
|
||||
* an observed `finish` cannot be re-sent.
|
||||
*/
|
||||
public function test_a_replayed_ceremony_fails(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
|
||||
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||
$challenge = Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']);
|
||||
|
||||
$first = $this->finishRegistration($client, $started['ceremonyId'], $challenge);
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $first->getStatusCode());
|
||||
|
||||
/* a replay comes from someone who does not hold the session the first
|
||||
* attempt just created, so the cookie must go — otherwise AcceptListener
|
||||
* answers 200 and the ceremony listener never sees the replay */
|
||||
$replay = $this->finishRegistration($this->freshVisitor(), $started['ceremonyId'], $challenge);
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $replay->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* An assertion signed over a different challenge must be refused, which is
|
||||
* what binds a login to this session rather than to any past one.
|
||||
*/
|
||||
public function test_an_assertion_for_another_challenge_fails(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
|
||||
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||
$registered = $this->finishRegistration(
|
||||
$client,
|
||||
$started['ceremonyId'],
|
||||
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||
);
|
||||
/* the stored record's counter is the one the registration used, and the
|
||||
* lenient policy accepts an equal or greater value */
|
||||
$counter = $this->helper()->counter() + 1;
|
||||
|
||||
$client = $this->freshVisitor();
|
||||
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||
], '{}');
|
||||
$begin = json_decode((string) $client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($begin);
|
||||
|
||||
/* sign a challenge the server never issued */
|
||||
$assertion = $this->helper()->assertionCredential(
|
||||
self::RP_ID,
|
||||
random_bytes(32),
|
||||
self::ORIGIN,
|
||||
$this->credentialId(),
|
||||
$counter,
|
||||
hash('sha256', self::IDENTITY, true),
|
||||
);
|
||||
|
||||
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-finish',
|
||||
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* An unknown credential must fail with the same generic message a wrong code
|
||||
* gets, so the endpoint cannot be used to enumerate live credentials.
|
||||
*/
|
||||
public function test_an_unknown_credential_fails_generically(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
|
||||
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||
], '{}');
|
||||
$begin = json_decode((string) $client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($begin);
|
||||
|
||||
/* a credential nobody registered, signed correctly against this challenge */
|
||||
$assertion = $this->helper()->assertionCredential(
|
||||
self::RP_ID,
|
||||
Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']),
|
||||
self::ORIGIN,
|
||||
random_bytes(16),
|
||||
1,
|
||||
hash('sha256', 'nobody', true),
|
||||
);
|
||||
|
||||
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-finish',
|
||||
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
|
||||
/* and the wording matches the ordinary failure, with no hint that the
|
||||
* credential was unknown */
|
||||
$content = json_decode((string) $response->getContent(), true);
|
||||
self::assertIsArray($content);
|
||||
self::assertSame('Unsuccessful login attempt', $content['message']);
|
||||
}
|
||||
|
||||
/* ── the shared session (why SessionIssuer exists) ────────────────── */
|
||||
|
||||
/**
|
||||
* Both login paths must produce the *same* cookie, or a user would appear
|
||||
* logged in on the auth subdomain but not on the protected one.
|
||||
*/
|
||||
public function test_a_passkey_login_sets_the_same_cookie_as_a_code_login(): void
|
||||
{
|
||||
/* a code login, for comparison — via the same AJAX path the passkey
|
||||
* script uses, so any difference is in the cookie and nothing else */
|
||||
$codeCookie = $this->codeLoginCookie();
|
||||
|
||||
/* Now the passkey path, on a visitor with no session: the code login
|
||||
* above set a cookie, and with it the login page is replaced by
|
||||
* AcceptListener's "already authenticated" reply. */
|
||||
$client = $this->freshVisitor();
|
||||
$started = $this->beginRegistration($client, $this->nonceFrom($client));
|
||||
$registered = $this->finishRegistration(
|
||||
$client,
|
||||
$started['ceremonyId'],
|
||||
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
|
||||
);
|
||||
$passkeyCookie = $this->authCookieFrom($registered);
|
||||
|
||||
self::assertSame($codeCookie->getName(), $passkeyCookie->getName());
|
||||
self::assertSame($codeCookie->getDomain(), $passkeyCookie->getDomain());
|
||||
self::assertSame($codeCookie->getPath(), $passkeyCookie->getPath());
|
||||
self::assertSame($codeCookie->isSecure(), $passkeyCookie->isSecure());
|
||||
self::assertSame($codeCookie->isHttpOnly(), $passkeyCookie->isHttpOnly());
|
||||
self::assertSame($codeCookie->getSameSite(), $passkeyCookie->getSameSite());
|
||||
}
|
||||
|
||||
/* ── caching and availability ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A ceremony reply is a browser-facing 2xx, which nothing else in this app
|
||||
* produces, so it must carry the full no-store set or a browser could
|
||||
* replay a stale challenge.
|
||||
*/
|
||||
public function test_ceremony_responses_are_not_cacheable(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
|
||||
$client->request('POST', self::ORIGIN.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||
], '{}');
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
|
||||
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
|
||||
|
||||
/* the internal marker must not leak to the browser */
|
||||
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
|
||||
}
|
||||
|
||||
/**
|
||||
* With the feature off the listener must be inert, so a caller cannot even
|
||||
* obtain a challenge. This uses the default test environment, where
|
||||
* PASSKEY_ENABLED is 0.
|
||||
*/
|
||||
public function test_the_ceremony_is_inert_when_passkeys_are_disabled(): void
|
||||
{
|
||||
/* no passkey env set, so PASSKEY_ENABLED keeps its .env.test value of 0 */
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', 'https://'.self::AUTH_HOST.'/', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_X-Preauth-Passkey' => 'login-begin',
|
||||
], '{}');
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
|
||||
/* however the listener answered, the caller must not have been given a
|
||||
* challenge — that is the property under test */
|
||||
self::assertStringNotContainsString('publicKey', (string) $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* The login page must not offer what the server refuses, and vice versa.
|
||||
*/
|
||||
public function test_the_login_page_offers_passkeys_when_enabled(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
$client->request('GET', self::ORIGIN.'/');
|
||||
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('id="preauth-passkey"', $html);
|
||||
self::assertStringContainsString('id="preauth-register"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half: with the feature off the page must not offer anything.
|
||||
* Kept as its own test because a kernel may only be booted once, so the two
|
||||
* configurations cannot be compared within a single test.
|
||||
*/
|
||||
public function test_the_login_page_offers_nothing_when_passkeys_are_disabled(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/');
|
||||
|
||||
self::assertStringNotContainsString('preauth-passkey', (string) $client->getResponse()->getContent());
|
||||
self::assertStringNotContainsString('preauth-register', (string) $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSP must permit the two WebAuthn directives, because they do not fall
|
||||
* back to `default-src` and the browser refuses the ceremony without them.
|
||||
*/
|
||||
public function test_the_csp_permits_the_ceremony_when_passkeys_are_enabled(): void
|
||||
{
|
||||
$client = $this->createPasskeyClient();
|
||||
$client->request('GET', self::ORIGIN.'/');
|
||||
|
||||
$csp = (string) $client->getResponse()->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString("publickey-credentials-get 'self'", $csp);
|
||||
self::assertStringContainsString("publickey-credentials-create 'self'", $csp);
|
||||
}
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
private function authCookieFrom(Response $response): \Symfony\Component\HttpFoundation\Cookie
|
||||
{
|
||||
foreach ($response->headers->getCookies() as $cookie) {
|
||||
if (self::AUTH_COOKIE === $cookie->getName()) {
|
||||
return $cookie;
|
||||
}
|
||||
}
|
||||
|
||||
self::fail('Expected an auth cookie in the response.');
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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) ─────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -443,4 +417,22 @@ final class PasskeyListenerTest extends TestCase
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty body is not an error in itself: the ceremony id is simply
|
||||
* missing, so the request is refused the same way a malformed one is.
|
||||
* Asserted separately because it is the shape a bare `fetch()` produces.
|
||||
*/
|
||||
public function test_an_empty_body_is_refused(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('finishLogin')->willReturn(null);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-finish'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\AppConstants;
|
||||
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;
|
||||
@@ -17,6 +19,7 @@ use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use ReflectionProperty;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
@@ -35,6 +38,7 @@ final class LoginManagerTest extends TestCase
|
||||
?int $ipTtl = 0,
|
||||
bool $subdomainRedirect = false,
|
||||
string $authSubdomain = '',
|
||||
?PasskeyInterface $passkeys = null,
|
||||
): LoginManager {
|
||||
$this->pool = new ArrayAdapter();
|
||||
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
||||
@@ -45,7 +49,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, $passkeys ?? $this->createStub(PasskeyInterface::class));
|
||||
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new ArrayAdapter());
|
||||
@@ -375,7 +379,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());
|
||||
@@ -462,4 +466,83 @@ final class LoginManagerTest extends TestCase
|
||||
// should fall back to path since empty string is not a valid URL
|
||||
self::assertStringStartsWith('/', $location);
|
||||
}
|
||||
|
||||
/* ── the passkey registration hand-off ────────────────────────────── */
|
||||
|
||||
/**
|
||||
* With the intent set, a successful check must return ceremony options
|
||||
* rather than a session — beginning the ceremony from the place that has
|
||||
* already verified both the code and the nonce.
|
||||
*/
|
||||
public function test_a_registration_intent_returns_ceremony_options(): void
|
||||
{
|
||||
$passkeys = $this->createMock(PasskeyInterface::class);
|
||||
$passkeys->expects(self::once())
|
||||
->method('beginRegistration')
|
||||
->with('testuser')
|
||||
->willReturn(['publicKey' => ['challenge' => 'abc'], 'ceremonyId' => 'cid']);
|
||||
|
||||
$manager = $this->makeLoginManager(passkeys: $passkeys);
|
||||
|
||||
$payload = $this->makePayloadWithNonce($manager);
|
||||
$payload->register = true;
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$response = $manager->checkToken($payload, Request::create('/', 'GET'));
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
|
||||
/* a ceremony reply is browser-facing, so it must carry the marker that
|
||||
* becomes the no-store policy */
|
||||
self::assertSame('1', $response->headers->get(AppConstants::PASSKEY_CEREMONY_MARKER));
|
||||
|
||||
$decoded = json_decode((string) $response->getContent(), true);
|
||||
self::assertSame('cid', $decoded['register']['ceremonyId']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration does **not** grant a session: the credential is not verified
|
||||
* until register-finish, so issuing one now would hand out access for a
|
||||
* ceremony that has not happened.
|
||||
*/
|
||||
public function test_a_registration_intent_sets_no_session_cookie(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('beginRegistration')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
|
||||
|
||||
$manager = $this->makeLoginManager(passkeys: $passkeys);
|
||||
|
||||
$payload = $this->makePayloadWithNonce($manager);
|
||||
$payload->register = true;
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$response = $manager->checkToken($payload, Request::create('/', 'GET'));
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertFalse($response->headers->has('Set-Cookie'));
|
||||
self::assertFalse($response->headers->has('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A ceremony that cannot start must not become a 500 on the login page: it
|
||||
* falls through to the same failure path a wrong code takes.
|
||||
*/
|
||||
public function test_a_ceremony_that_cannot_start_fails_like_a_wrong_code(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('beginRegistration')->willThrowException(new RuntimeException('no ceremony'));
|
||||
|
||||
$manager = $this->makeLoginManager(passkeys: $passkeys);
|
||||
|
||||
$payload = $this->makePayloadWithNonce($manager);
|
||||
$payload->register = true;
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
self::assertNull($manager->checkToken($payload, Request::create('/', 'GET')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\Service\PasskeyCeremonyFactory;
|
||||
use App\Service\PasskeyCredentialStoreInterface;
|
||||
use App\Service\PasskeyManager;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Tests\Support\PasskeyTestHelper;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Throwable;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\TrustPath\EmptyTrustPath;
|
||||
|
||||
/**
|
||||
* The ceremony control flow, with a stubbed validator.
|
||||
*
|
||||
* Real cryptography is proven separately (PasskeyRealCryptoSpikeTest and the
|
||||
* functional suite); this file is about the branches around it — what happens
|
||||
* when the store is empty, the body is malformed, or verification fails. Those
|
||||
* are the paths a browser is least likely to exercise on purpose and an attacker
|
||||
* most likely to.
|
||||
*/
|
||||
final class PasskeyManagerTest extends TestCase
|
||||
{
|
||||
private const string RP_ID = 'example.com';
|
||||
|
||||
private const string ORIGIN = 'https://auth.example.com';
|
||||
|
||||
private ?ArrayAdapter $cache = null;
|
||||
|
||||
private function makePolicy(): PasskeyPolicyInterface
|
||||
{
|
||||
$policy = $this->createStub(PasskeyPolicyInterface::class);
|
||||
$policy->method('rpId')->willReturn(self::RP_ID);
|
||||
$policy->method('authSubdomain')->willReturn('auth.example.com');
|
||||
$policy->method('allowedOrigins')->willReturn([self::ORIGIN]);
|
||||
$policy->method('rpName')->willReturn('Preauth');
|
||||
$policy->method('userVerification')->willReturn('required');
|
||||
$policy->method('timeout')->willReturn(60000);
|
||||
$policy->method('isEnabled')->willReturn(true);
|
||||
$policy->method('isAvailableFor')->willReturn(true);
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PasskeyCredential[] $credentials
|
||||
*/
|
||||
private function makeManager(
|
||||
array $credentials = [],
|
||||
?PasskeyCredentialStoreInterface $store = null,
|
||||
): PasskeyManager {
|
||||
$this->cache = new ArrayAdapter();
|
||||
|
||||
$store ??= $this->makeStore($credentials);
|
||||
|
||||
return new PasskeyManager(
|
||||
$this->makePolicy(),
|
||||
new \App\Service\PasskeyCeremonyStore($this->cache),
|
||||
$store,
|
||||
new PasskeyCeremonyFactory(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PasskeyCredential[] $credentials
|
||||
*/
|
||||
private function makeStore(array $credentials): PasskeyCredentialStoreInterface
|
||||
{
|
||||
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
|
||||
$store->method('all')->willReturn($credentials);
|
||||
$store->method('find')->willReturnCallback(
|
||||
static function (string $id) use ($credentials): ?PasskeyCredential {
|
||||
foreach ($credentials as $credential) {
|
||||
if ($credential->record->publicKeyCredentialId === $id) {
|
||||
return $credential;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
return $store;
|
||||
}
|
||||
|
||||
private function makeCredential(string $identity = 'lyra'): PasskeyCredential
|
||||
{
|
||||
return new PasskeyCredential(
|
||||
CredentialRecord::create(
|
||||
random_bytes(16),
|
||||
'public-key',
|
||||
['internal'],
|
||||
'none',
|
||||
EmptyTrustPath::create(),
|
||||
Uuid::v4(),
|
||||
'COSE_KEY',
|
||||
hash('sha256', $identity, true),
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
$identity,
|
||||
'Passkey abc',
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── begin ────────────────────────────────────────────────────────── */
|
||||
|
||||
public function test_begin_login_returns_options_and_a_ceremony_id(): void
|
||||
{
|
||||
$result = $this->makeManager()->beginLogin();
|
||||
|
||||
self::assertArrayHasKey('publicKey', $result);
|
||||
self::assertArrayHasKey('ceremonyId', $result);
|
||||
self::assertSame(self::RP_ID, $result['publicKey']['rpId']);
|
||||
}
|
||||
|
||||
/**
|
||||
* With no credentials registered the list is empty rather than absent, so
|
||||
* the browser can still offer a discoverable credential.
|
||||
*/
|
||||
public function test_begin_login_with_no_credentials_offers_an_empty_list(): void
|
||||
{
|
||||
$result = $this->makeManager()->beginLogin();
|
||||
|
||||
self::assertArrayHasKey('allowCredentials', $result['publicKey']);
|
||||
self::assertSame([], $result['publicKey']['allowCredentials']);
|
||||
}
|
||||
|
||||
public function test_begin_login_lists_every_registered_credential(): void
|
||||
{
|
||||
$manager = $this->makeManager([$this->makeCredential('lyra'), $this->makeCredential('atlas')]);
|
||||
|
||||
$result = $manager->beginLogin();
|
||||
|
||||
self::assertCount(2, $result['publicKey']['allowCredentials']);
|
||||
}
|
||||
|
||||
public function test_begin_registration_uses_the_given_identity(): void
|
||||
{
|
||||
$result = $this->makeManager()->beginRegistration('lyra');
|
||||
|
||||
self::assertArrayHasKey('ceremonyId', $result);
|
||||
self::assertSame('lyra', $result['publicKey']['user']['name']);
|
||||
self::assertSame('none', $result['publicKey']['attestation']);
|
||||
}
|
||||
|
||||
/* ── finish: malformed input ──────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A body without a ceremony id or credential must be refused, and must not
|
||||
* touch the credential store.
|
||||
*/
|
||||
public function test_finish_login_refuses_a_body_without_a_ceremony_id(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
self::assertNull($manager->finishLogin([]));
|
||||
self::assertNull($manager->finishLogin(['credential' => []]));
|
||||
self::assertNull($manager->finishLogin(['ceremonyId' => '', 'credential' => []]));
|
||||
}
|
||||
|
||||
public function test_finish_login_refuses_a_body_without_a_credential(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
self::assertNull($manager->finishLogin(['ceremonyId' => 'cid']));
|
||||
self::assertNull($manager->finishLogin(['ceremonyId' => 'cid', 'credential' => 'not-an-array']));
|
||||
}
|
||||
|
||||
/**
|
||||
* An unknown ceremony id means the record was never issued, already spent,
|
||||
* or expired — all of which must look the same to the caller.
|
||||
*/
|
||||
public function test_finish_login_refuses_an_unknown_ceremony_id(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
self::assertNull($manager->finishLogin([
|
||||
'ceremonyId' => 'never-issued',
|
||||
'credential' => ['id' => 'x'],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential the store does not know must be refused before any
|
||||
* verification is attempted, so an attacker cannot use the endpoint as an
|
||||
* oracle by nominating arbitrary credential ids.
|
||||
*/
|
||||
public function test_finish_login_refuses_an_unknown_credential(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
$started = $manager->beginLogin();
|
||||
|
||||
/* a structurally valid assertion for a credential nobody registered */
|
||||
$helper = new PasskeyTestHelper();
|
||||
$challenge = $this->challengeFor($started['ceremonyId']);
|
||||
$assertion = $helper->assertionCredential(
|
||||
self::RP_ID,
|
||||
$challenge,
|
||||
self::ORIGIN,
|
||||
random_bytes(16),
|
||||
1,
|
||||
hash('sha256', 'nobody', true),
|
||||
);
|
||||
|
||||
self::assertNull($manager->finishLogin([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => $assertion,
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_finish_registration_refuses_malformed_input(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
|
||||
self::assertNull($manager->finishRegistration([]));
|
||||
self::assertNull($manager->finishRegistration(['ceremonyId' => 'cid']));
|
||||
self::assertNull($manager->finishRegistration(['ceremonyId' => 'never-issued', 'credential' => []]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A login ceremony must not be usable to finish a registration, or the two
|
||||
* flows' differing trust assumptions would blur together.
|
||||
*/
|
||||
public function test_a_login_ceremony_cannot_finish_a_registration(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
$started = $manager->beginLogin();
|
||||
|
||||
self::assertNull($manager->finishRegistration([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => [],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A registration ceremony must not be usable to finish a login.
|
||||
*/
|
||||
public function test_a_registration_ceremony_cannot_finish_a_login(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
$started = $manager->beginRegistration('lyra');
|
||||
|
||||
self::assertNull($manager->finishLogin([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => [],
|
||||
]));
|
||||
}
|
||||
|
||||
/* ── finish: verification failure ─────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A wrong challenge must fail, and must not be retryable: the record is
|
||||
* consumed on read.
|
||||
*/
|
||||
public function test_a_wrong_challenge_fails_and_is_not_retryable(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
|
||||
/* a store holding a credential whose id matches the assertion */
|
||||
$record = CredentialRecord::create(
|
||||
$credentialId,
|
||||
'public-key',
|
||||
['internal'],
|
||||
'none',
|
||||
EmptyTrustPath::create(),
|
||||
Uuid::v4(),
|
||||
'COSE_KEY',
|
||||
hash('sha256', 'lyra', true),
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
$stored = new PasskeyCredential($record, 'lyra', 'Passkey abc', new DateTimeImmutable());
|
||||
|
||||
$manager = $this->makeManager([$stored]);
|
||||
$started = $manager->beginLogin();
|
||||
|
||||
$assertion = $helper->assertionCredential(
|
||||
self::RP_ID,
|
||||
random_bytes(32),
|
||||
self::ORIGIN,
|
||||
$credentialId,
|
||||
0,
|
||||
hash('sha256', 'lyra', true),
|
||||
);
|
||||
|
||||
self::assertNull($manager->finishLogin([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => $assertion,
|
||||
]));
|
||||
|
||||
/* and the same ceremony cannot be presented again */
|
||||
self::assertNull($manager->finishLogin([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => $assertion,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A store that throws must not turn a malformed credential into a 500.
|
||||
*/
|
||||
public function test_a_store_failure_is_reported_as_a_failed_ceremony(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
|
||||
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
|
||||
$store->method('find')->willThrowException(new RuntimeException('store down'));
|
||||
|
||||
$manager = $this->makeManager(store: $store);
|
||||
$started = $manager->beginLogin();
|
||||
|
||||
$assertion = $helper->assertionCredential(
|
||||
self::RP_ID,
|
||||
random_bytes(32),
|
||||
self::ORIGIN,
|
||||
$credentialId,
|
||||
0,
|
||||
hash('sha256', 'lyra', true),
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $manager->finishLogin([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => $assertion,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
self::fail('finishLogin() must not throw: '.$exception->getMessage());
|
||||
}
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The challenge the manager issued for a ceremony, read back from the cache
|
||||
* the way an attacker with the ceremony id would not be able to.
|
||||
*/
|
||||
private function challengeFor(string $ceremonyId): string
|
||||
{
|
||||
$key = new ReflectionMethod(\App\Service\PasskeyCeremonyStore::class, 'key');
|
||||
$store = new \App\Service\PasskeyCeremonyStore($this->cache);
|
||||
$item = $this->cache->getItem($key->invoke($store, $ceremonyId));
|
||||
$payload = $item->isHit() ? $item->get() : null;
|
||||
|
||||
return \is_array($payload) && isset($payload['challenge']) ? (string) $payload['challenge'] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential whose stored payload cannot be read must be treated as
|
||||
* unusable, and — importantly — must not throw. One corrupt entry must not
|
||||
* become a 500 for every visitor on the login page.
|
||||
*/
|
||||
public function test_a_credential_that_cannot_be_found_fails_the_ceremony(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
|
||||
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
|
||||
$store->method('find')->willReturn(null);
|
||||
|
||||
$manager = $this->makeManager(store: $store);
|
||||
$started = $manager->beginLogin();
|
||||
|
||||
$assertion = $helper->assertionCredential(
|
||||
self::RP_ID,
|
||||
random_bytes(32),
|
||||
self::ORIGIN,
|
||||
$credentialId,
|
||||
0,
|
||||
hash('sha256', 'lyra', true),
|
||||
);
|
||||
|
||||
self::assertNull($manager->finishLogin([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => $assertion,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A registration whose attestation cannot be parsed must fail rather than
|
||||
* throwing, for the same reason.
|
||||
*/
|
||||
public function test_unparseable_attestation_fails_the_ceremony(): void
|
||||
{
|
||||
$manager = $this->makeManager();
|
||||
$started = $manager->beginRegistration('lyra');
|
||||
|
||||
/* structurally a credential object, but the response is nonsense */
|
||||
self::assertNull($manager->finishRegistration([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => ['id' => 'x', 'rawId' => 'x', 'type' => 'public-key', 'response' => []],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A store that cannot persist a registration must not report success: the
|
||||
* user would believe the passkey was saved and then find it missing at the
|
||||
* next login, with nothing to explain why.
|
||||
*/
|
||||
public function test_a_registration_that_cannot_be_persisted_fails(): void
|
||||
{
|
||||
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
|
||||
$store->method('all')->willReturn([]);
|
||||
$store->method('find')->willReturn(null);
|
||||
$store->method('save')->willThrowException(new RuntimeException('store down'));
|
||||
|
||||
$helper = new PasskeyTestHelper();
|
||||
$manager = $this->makeManager(store: $store);
|
||||
$started = $manager->beginRegistration('lyra');
|
||||
$challenge = $this->challengeFor($started['ceremonyId']);
|
||||
|
||||
$credential = $helper->registrationCredential(self::RP_ID, $challenge, self::ORIGIN);
|
||||
|
||||
self::assertNull($manager->finishRegistration([
|
||||
'ceremonyId' => $started['ceremonyId'],
|
||||
'credential' => $credential,
|
||||
]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user