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.
4.7 KiB
← 07 — Failure modes · index · next: 09 — Persistence
The one-time-token fallback
oneTimeTokenLogin is Spring Security's magic link. It exists independently of passkeys, but
it is the natural partner: it solves the bootstrap problem in 06
and it is the recovery path when a device is lost.
.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(handler))
It will not start without a delivery handler
There is no default OneTimeTokenGenerationSuccessHandler, and there cannot be a sensible one
— Spring has no idea whether you send email, SMS or a push. Omit the bean and the
context fails to start. That is the correct decision and it is also the first error everybody
hits.
The handler in this module prints the link and writes the token to a file so the demo scripts can read it:
System.out.printf("[one-time-token] username=%s expires=%s%n[one-time-token] %s%n",
oneTimeToken.getUsername(), oneTimeToken.getExpiresAt(), link);
Files.writeString(TOKEN_FILE, oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
this.redirect.handle(request, response, oneTimeToken);
The redirect.handle(..) at the end is not optional: the handler owns the HTTP response, so
if it does not write one the browser gets a blank page.
RedirectOneTimeTokenGenerationSuccessHandler("/login/ott") sends the user to the built-in
submit page.
The flow, and the endpoints
| method | path | what it does |
|---|---|---|
POST |
/ott/generate |
takes username, generates a token, calls your handler |
GET |
/login/ott |
the built-in submit page, prefilled if ?token= is present |
POST |
/login/ott |
redeems the token and creates the session |
From docs/output/pk-ott.txt:
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
token delivered out of band (the handler wrote it to a file): 3203202f-7e17-4f8b-a613-0d64831c3530
POST /login/ott -> HTTP 302, Location: http://localhost:8080/
$ GET /me
{"name":"user","authenticationType":"OneTimeTokenAuthentication",
"authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]}
Single use is enforced — OneTimeTokenService.consume removes it:
=== The same token, a second time ===
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error
It does not tell an attacker whether the account exists
InMemoryOneTimeTokenService.generate takes a GenerateOneTimeTokenRequest carrying a
username and nothing else. There is no UserDetailsService involved, so it cannot check
whether the user exists, and it does not try. From
docs/output/pk-bootstrap.txt, for a username that is not in
the UserDetailsService:
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
a token was still generated and delivered: f9f58b17-f2ee-498c-86fe-da3570f9108e
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error (the failure lands here instead)
Byte-identical to a real username, so there is no account-enumeration oracle at the generate
endpoint. The failure happens at redemption, in OneTimeTokenAuthenticationProvider, where
nobody is listening.
This falls out of the design rather than being aimed at, and it is currently an open question in the project — spring-security#16483 argues that a token should not be created for a user who does not exist. If that changes, check that the response for an unknown username still matches the response for a known one, because it is very easy to fix the storage waste and open an enumeration hole in the same commit.
Defaults worth changing
| setting | default | why you might move it |
|---|---|---|
| token TTL | 5 minutes (GenerateOneTimeTokenRequest) |
shorter for recovery, since the mail arrives in seconds |
OneTimeTokenService |
InMemoryOneTimeTokenService |
JdbcOneTimeTokenService for more than one instance; the DDL is org/springframework/security/core/ott/jdbc/one-time-tokens-schema.sql |
| token value | a UUID | GenerateOneTimeTokenRequestResolver if you need a short numeric code for SMS |
| rate limiting | none | there is none. /ott/generate will happily mail somebody a hundred links |
That last row is the one to act on. A magic-link endpoint with no rate limit is a mail bomb and a nuisance-denial-of-service against your own sending reputation. Spring Security does not ship a limiter; put one in front.
← 07 — Failure modes · index · next: 09 — Persistence