Fourth Maven project in the repository. Registration and authentication run end to end with no browser and no hardware key: VirtualAuthenticator emits real CBOR attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java drives the live HTTP endpoints with them. Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
107 lines
5.6 KiB
Markdown
107 lines
5.6 KiB
Markdown
[← 05 — The defaults](05-defaults.md) · [index](README.md) · next: [07 — Failure modes](07-failure-modes.md)
|
|
|
|
# The bootstrap problem
|
|
|
|
A passkey cannot be a user's first credential. This is not a Spring Security limitation, it is
|
|
the shape of the protocol, and it is the thing that turns “add passkeys” from an
|
|
afternoon into a project.
|
|
|
|
## Where it is enforced
|
|
|
|
```java
|
|
Authentication authentication = request.getAuthentication();
|
|
if (!this.trustResolver.isAuthenticated(authentication)) {
|
|
throw new IllegalArgumentException("Authentication must be authenticated");
|
|
}
|
|
```
|
|
|
|
— `Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions`
|
|
|
|
`AuthenticationTrustResolverImpl.isAuthenticated` returns false for null and for anonymous, so
|
|
registration options are only issued to a session that already proved who it belongs to. And
|
|
they have to be: the options object contains `user.id` and `user.name`, and the credential is
|
|
bound to them. There is nobody to bind to until somebody has logged in.
|
|
|
|
From [`docs/output/pk-bootstrap.txt`](../output/pk-bootstrap.txt):
|
|
|
|
```
|
|
$ POST /webauthn/register/options (anonymous)
|
|
HTTP 400
|
|
(empty body)
|
|
```
|
|
|
|
Note the status. Not 401, not a redirect to the login page — a bare **400** with no
|
|
body, because `PublicKeyCredentialCreationOptionsFilter` sits before `AuthorizationFilter` and
|
|
the `IllegalArgumentException` surfaces as a bad request. A single-page front end whose session
|
|
has expired will see a 400 from an endpoint that worked a minute ago, and nothing about that
|
|
response says “log in again”. Handle it explicitly on the client.
|
|
|
|
## So what carries the user in?
|
|
|
|
Something else always does. The realistic options, in rough order of how often they are the
|
|
right answer:
|
|
|
|
| route | good for | cost |
|
|
|---|---|---|
|
|
| **One-time token** by email or SMS | new signups, and recovery after a lost device | you own a delivery channel and its failure modes |
|
|
| **Existing password** | adding passkeys to an application that already has users | the password stays, so the phishing surface stays |
|
|
| **Federated login** (OIDC, social) | consumer products that already federate | the identity provider becomes your recovery story |
|
|
| **Support desk** | enterprises with an existing identity-proofing process | expensive, and now the desk is the attack surface |
|
|
|
|
This module uses the first, because Spring Security ships it: `oneTimeTokenLogin` is one line
|
|
of DSL, and it covers both the way in and the way back. See
|
|
[08 — The one-time-token fallback](08-one-time-token-fallback.md).
|
|
|
|
## The recovery half is the hard half
|
|
|
|
Registration is a solved problem the moment the user is logged in. Recovery is not, and the
|
|
awkward truth is that **your recovery path sets your real security level**. A passkey that
|
|
cannot be phished, backed by an email magic link that can be, is an application whose security
|
|
is that of the email account.
|
|
|
|
The mitigations are all trade-offs, and none of them are free:
|
|
|
|
- **Require two passkeys.** A phone and a laptop, or a phone and a hardware key. Excellent, and roughly half of users will not do it.
|
|
- **Rate-limit and age-limit recovery.** A magic link that logs you in but will not register a new passkey for 24 hours turns an account takeover into a race you can notice.
|
|
- **Notify on registration.** Any new credential produces a mail to every address on file. Cheap, and it is how takeovers actually get caught.
|
|
- **Step up for sensitive actions.** Recovery gets you in; it does not get you a password change or a payout.
|
|
|
|
Spring Security gives you the primitives for the last two: `AuthenticationSuccessHandler` for
|
|
notification, and `FactorGrantedAuthority` for step-up. A session established by one-time token
|
|
carries `FACTOR_OTT`; one established by a passkey carries `FACTOR_WEBAUTHN`. From
|
|
[`docs/output/pk-ott.txt`](../output/pk-ott.txt) and
|
|
[`docs/output/pk-ceremony.txt`](../output/pk-ceremony.txt):
|
|
|
|
```json
|
|
{"name":"user","authenticationType":"OneTimeTokenAuthentication",
|
|
"authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]}
|
|
|
|
{"name":"user","authenticationType":"WebAuthnAuthentication",
|
|
"authorities":["FactorGrantedAuthority [authority=FACTOR_WEBAUTHN, issuedAt=...]","ROLE_USER"]}
|
|
```
|
|
|
|
Those are ordinary authorities, so `hasAuthority("FACTOR_WEBAUTHN")` is all it takes to
|
|
require a real passkey on an endpoint that a magic link should not reach:
|
|
|
|
```java
|
|
.requestMatchers("/passkey-only").hasAuthority("FACTOR_WEBAUTHN")
|
|
```
|
|
|
|
What happens when the authority is missing is better than a 403. `WebAuthnConfigurer.init`
|
|
registers `defaultDeniedHandlerForMissingAuthority(.., FactorGrantedAuthority.WEBAUTHN_AUTHORITY)`
|
|
with a `LoginUrlAuthenticationEntryPoint("/login")`, so Spring Security turns "you are
|
|
authenticated but not with a passkey" into a redirect that says which factor is missing. From
|
|
[`docs/output/pk-step-up.txt`](../output/pk-step-up.txt), the same user in three sessions:
|
|
|
|
```
|
|
password session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing
|
|
one-time-token session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing
|
|
passkey session -> GET /passkey-only: HTTP 200 {"ok":"this endpoint required FACTOR_WEBAUTHN"}
|
|
```
|
|
|
|
Those query parameters are generated for you and are not mentioned on the passkeys reference
|
|
page. A login page that reads `factor.reason=missing` can say “use your passkey for
|
|
this” instead of “access denied”.
|
|
|
|
[← 05 — The defaults](05-defaults.md) · [index](README.md) · next: [07 — Failure modes](07-failure-modes.md)
|