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.
100 lines
4.7 KiB
Markdown
100 lines
4.7 KiB
Markdown
[← 07 — Failure modes](07-failure-modes.md) · [index](README.md) · next: [09 — Persistence](09-persistence.md)
|
|
|
|
# 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](06-the-bootstrap-problem.md)
|
|
and it is the recovery path when a device is lost.
|
|
|
|
```java
|
|
.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:
|
|
|
|
```java
|
|
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`](../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`](../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](https://github.com/spring-projects/spring-security/issues/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](07-failure-modes.md) · [index](README.md) · next: [09 — Persistence](09-persistence.md)
|