Add passkeys project: WebAuthn ceremonies, a software authenticator and the one-time-token fallback
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.
This commit is contained in:
99
docs/passkeys/08-one-time-token-fallback.md
Normal file
99
docs/passkeys/08-one-time-token-fallback.md
Normal file
@@ -0,0 +1,99 @@
|
||||
[← 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)
|
||||
Reference in New Issue
Block a user