feat(registration): add email confirmation flow
Build, Test & Push Auth Service / quality-check (push) Successful in 58s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s

Registrations become two-phase: first UNCONFIRMED with a 24-hour
single-use email link, then PENDING after confirmation and password
entry. Prevents registering with someone else's address. Admins see
unconfirmed requests and can delete them; they cannot accept them.
This commit is contained in:
lobo committed 2026-10-01 16:20:51 +02:00
1 parent 878ede914b
commit 0af76da9c7
43 files changed
+959 -145

No files matched your search

+22 -8
View File
@@ -13,7 +13,8 @@ To Be Played is split into three repositories:
## Features
- Registration by admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
- Registration by email confirmation (a single-use link, valid 24 hours, on whose page the owner chooses the
password; unconfirmed requests are deleted after 7 days) and then admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit).
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations
per IP, block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
@@ -83,7 +84,7 @@ To Be Played is split into three repositories:
set it (`PUT /api/users/settings/welcome-seen`). V24 sets it for every existing user, so only users registered after
the deploy get the page.
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
to the admin page), and to the user "we got your registration", then "your account is active" or "your
to the admin page), and to the user "confirm your address" (`CONFIRM_EMAIL`, sent first; the admin mail follows the click), then "your account is active" or "your
registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
the user's app language. Plain text only. See "Mail flow".
- **Broadcast mail**: a PLATFORM_ADMIN writes one mail with subject and text in English and German (plain text),
@@ -105,6 +106,8 @@ To Be Played is split into three repositories:
| Method | Path | Auth |
|---|---|---|
| `POST` | `/api/registration/new` | public |
| `POST` | `/api/registration/confirm` (`{token, password}`; `204`; `410 CONFIRMATION_INVALID`) | public |
| `POST` | `/api/registration/resend` (`{email}`; always `204`) | public |
| `POST` | `/api/auth/login` | public, sets both cookies |
| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
@@ -113,11 +116,12 @@ To Be Played is split into three repositories:
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
| `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie |
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` UNCONFIRMED, PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token |
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token |
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN |
@@ -304,7 +308,14 @@ sequenceDiagram
### Registration flow
A registration is a request: the account is PENDING until a PLATFORM_ADMIN decides. While 20 wait, the
A registration is a request: the account is UNCONFIRMED until the address owner opens the link in the mail (24 hours,
single use, only its SHA-256 is stored; a resend replaces it) and chooses the password there, then PENDING until a
PLATFORM_ADMIN decides. The registration form has no password: an unconfirmed user has none and cannot log in (login
answers the same `401` as for an unknown address), so nobody can set the password of an address that is not theirs.
The admin list shows an unconfirmed request as such; it cannot be accepted, the admin can delete it for good. The badge counts PENDING only and the admin mail follows the confirmation. The nightly cleanup deletes it after 7 days (not while a resent link is still valid), also in dry run.
Registering again with an unconfirmed address is a resend: a new link, no second row. At most 3 confirmation mails per
address and hour (over all IPs, registration and resend together); over that the answer stays the same and no mail is
sent. Unconfirmed requests count toward the 20 that wait. While 20 wait, the
registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21.
```mermaid
@@ -313,10 +324,13 @@ sequenceDiagram
participant A as Auth
participant D as Admin
U->>A: POST /api/registration/new
A->>A: users.status = PENDING, two mail rows
A->>A: users.status = UNCONFIRMED, one mail row
A-->>U: mail "confirm your address" (link)
U->>A: POST /api/registration/confirm {token, password}
A->>A: users.status = PENDING, password hash stored
A-->>D: mail "a new registration waits"
A-->>U: mail "we got your registration"
U->>A: login: 403 ACCOUNT_PENDING
Note over A: nightly: UNCONFIRMED older than 7 days is deleted
D->>A: PUT /api/admin/users/{id}/decision {accepted}
alt accepted
A->>A: status = ACTIVE