Public Access
495 lines
35 KiB
Markdown
495 lines
35 KiB
Markdown
# To Be Played – Auth Service
|
||
|
||
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the
|
||
public profile, and it signs the JWTs that the Core service trusts.
|
||
|
||
To Be Played is split into three repositories:
|
||
|
||
| Repository | Role |
|
||
|---|---|
|
||
| **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
|
||
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||
| ToBePlayed-Frontend | React single-page app |
|
||
|
||
## Features
|
||
|
||
- 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).
|
||
- **Password reset**: "forgot password" mails a link (`/reset-password#<token>`, single use, valid 1 hour, only the
|
||
SHA-256 stored, a new mail replaces the old link). Only an ACTIVE user gets a working link; every other address gets
|
||
the same `204` and no mail. A reset ends all logins; "change password" (current password + new one) ends all logins
|
||
but the current one. A PLATFORM_ADMIN can send the same mail to an active user. Both send a "password changed" mail.
|
||
- 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
|
||
wrong password, and every failed login gets the same error.
|
||
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
||
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
||
public key as a JWK, so Core finds the right key after a rotation.
|
||
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
|
||
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
|
||
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
|
||
token that comes back revokes all tokens of that login. Logout revokes them too.
|
||
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
||
frontend origin. A `Bearer` header still works for API clients and tests.
|
||
- User settings: display name (unique, case-insensitive), language, dark mode.
|
||
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
|
||
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
||
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
||
foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but
|
||
unverified: a verified link from another account takes them away. Unlinking is possible.
|
||
- Appearance settings synced across devices: accent color, corner style, wide layout, reduce animations, background and
|
||
dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
||
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
|
||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
||
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
|
||
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
|
||
`style`. The ticket also names the user's `role`, so Core can show the free looks of an admin. The ticket's
|
||
claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_` tile is shown),
|
||
`profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
||
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
|
||
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
|
||
A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in
|
||
`src/test/resources/contract/auth-core-contract.json`, the same file as in Core
|
||
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares both).
|
||
- Errors: every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
|
||
`fields`, a duplicate email or display name `409 EMAIL_ALREADY_IN_USE` / `DISPLAY_NAME_ALREADY_IN_USE`, anything
|
||
unexpected `500 INTERNAL_ERROR` without details (`shared/api/GlobalExceptionController`).
|
||
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
|
||
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
|
||
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
|
||
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
|
||
refresh. Core checks the same claim.
|
||
**Role change**: an admin sets the role of another user on the admin users page (`PUT /api/admin/users/{id}/role`).
|
||
The change is a row in `role_change` (actor, old and new role, time; shown in the "Roles" dialog) and in
|
||
`admin_audit` (`ROLE_CHANGE`), sets `users.role_changed_at` and clears "view as" of the open logins. An access
|
||
token issued before `role_changed_at` counts as not logged in (`401`) at Auth and at Core, within 30 s (cache
|
||
TTL); the frontend then refreshes and repeats the request, and the refresh reads the stored role. A role set by
|
||
SQL (the first admin) sets no `role_changed_at`; it works from the next refresh. There is never 0 admins: every
|
||
write that can remove an admin first locks the active admins (`shared/account/ActiveAdmins`, `SELECT ... FOR
|
||
UPDATE`), the actor must still be one of them, and the last admin cannot delete the own account.
|
||
- **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or
|
||
`@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the
|
||
active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
|
||
a denied call answers `401` without a login, else `403`. `@Public` skips only the role check: login, refresh,
|
||
"view as" and the Steam callback prove the caller themselves. The URL rules in `SecurityConfig` stay as the outer
|
||
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
|
||
service method calling another service's method that needs more.
|
||
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
|
||
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then
|
||
answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as
|
||
"Delete my account"). No action on the own account
|
||
or on another admin. Every action and every "view as" is a row in `admin_audit`.
|
||
Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the
|
||
right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
|
||
and sets `deleted_at`, so the login answers like a deleted account and the nightly cleanup removes it after 30
|
||
days. A waiting account cannot be blocked or deleted. Pending and denied users are nowhere public
|
||
(`shared/account/ActiveUser`).
|
||
- **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start
|
||
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
|
||
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
|
||
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
|
||
A hint can target groups, combined with AND (none = all users): without Steam, role (this role and higher, by the
|
||
active role of the token), new users (`users.registration_date` less than N days ago), empty library (Auth only
|
||
sends a flag; the Frontend checks it with Core). The text may use `**bold**` and `[text](url)`; the Frontend
|
||
renders it, never as HTML. Four automatic hints (`NO_STEAM`, `EMPTY_LIBRARY`, `PILE_GREW`, `LINK_QUEUE`) are on
|
||
by default; a PLATFORM_ADMIN turns each on or off (`auto_hint`, audited). `GET /api/hints` lists the keys that are
|
||
on and not hidden for the user; the Frontend checks the conditions. X hides one for 7 days, "Do not show again"
|
||
for good (`auto_hint_hidden`).
|
||
- **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no
|
||
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
|
||
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
|
||
entries. Editing an entry does not make it unseen again. Create, update and delete are rows in `admin_audit`.
|
||
- **Welcome page**: `user_settings.welcome_seen_at` is null until the user sees the welcome page; "Let's go" and "Skip"
|
||
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 "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>", and "reset your password" (`PASSWORD_RESET`, the link) and "your password was changed" (`PASSWORD_CHANGED`, after a reset or a change). 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),
|
||
and sends it to all active users or to picked ones, each in their app language. Every active user gets it.
|
||
"Send test to me" mails the draft to the admin only, in the admin's language. One `mail_broadcast` row keeps the
|
||
texts and the counts (recipients, sent, failed); `BROADCAST_SEND` goes to `admin_audit`. The sender takes account
|
||
mails first and stays under `APP_MAIL_DAILY_LIMIT` per 24 h, 10 of them kept for account mails. A recipient who is
|
||
blocked or deleted before the send counts as failed.
|
||
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
||
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
|
||
undo needs a new Steam link. A nightly job (04:15, Europe/Berlin) removes
|
||
users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a
|
||
**service token** (EdDSA JWT, 60 s, `aud=tbp-internal`). `app.account-cleanup.dry-run` (default `true`) only logs.
|
||
|
||
## API
|
||
|
||
| 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/password-reset` (`{email}`; always `204`; `429 TOO_MANY_ATTEMPTS`: 5 per IP and 15 min) | public |
|
||
| `POST` | `/api/auth/password-reset/confirm` (`{token, password}`; `204`; `410 RESET_INVALID`; `429`) | public |
|
||
| `POST` | `/api/auth/password` (`{currentPassword, newPassword}`; `204`; `403 INVALID_PASSWORD`; `429`; under `/api/auth` for the refresh cookie, which keeps the current login) | access token |
|
||
| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
|
||
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
|
||
| `GET` | `/api/users/me` | access token |
|
||
| `PUT` | `/api/users/settings` | access token |
|
||
| `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` 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`, also for an unconfirmed request) | PLATFORM_ADMIN |
|
||
| `PUT` | `/api/admin/users/{id}/role` (`{role, expectedRole}`; `204`; `400` for an unknown role; `404 USER_NOT_FOUND`; `409 SELF_ACTION`, `ACTOR_NOT_ADMIN`, `ROLE_CHANGED` (the role is not `expectedRole`), `ALREADY_DELETED`, `TARGET_IS_BLOCKED`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||
| `GET` | `/api/admin/users/{id}/role-changes` (`{results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}`, newest first, 50 at most; `actorEmail` null after the actor is purged) | 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`, `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 |
|
||
| `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||
| `GET` | `/api/hints` (`{results}`: running hints for this user not closed for good, newest first, with `emptyLibraryOnly`; `automatic`: keys of the automatic hints that are on and not hidden) | access token |
|
||
| `PUT` | `/api/hints/auto/{key}/snooze` (hide 7 days; `204`; `404 HINT_NOT_FOUND`) | access token |
|
||
| `PUT` | `/api/hints/auto/{key}/done` (hide for good; `204`; `404 HINT_NOT_FOUND`) | 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` and the `target*` fields, newest first; `automatic`: `[{key, enabled}]`) | PLATFORM_ADMIN |
|
||
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}` and optional `targetNoSteam`, `targetRole` (this role and higher), `targetNewDays` (1-365), `targetEmptyLibrary`; `201 {id}`, `400 INVALID_HINT` for the time range; `400 VALIDATION_FAILED` for a bad `targetNewDays` or `targetRole`) | PLATFORM_ADMIN |
|
||
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
|
||
| `PUT` | `/api/admin/hints/auto/{key}` (`{enabled}`; `204`; `404 HINT_NOT_FOUND`) | PLATFORM_ADMIN |
|
||
| `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
|
||
| `PUT` | `/api/news/seen` (`204`) | access token |
|
||
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
|
||
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN |
|
||
| `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||
| `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `409 LAST_ADMIN` (the only active admin), `429`) | access token |
|
||
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
||
| `PUT` | `/api/users/settings/appearance` | access token |
|
||
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
||
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token |
|
||
| `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | access token |
|
||
| `GET` | `/api/admin/broadcasts` (`{results}` with the counts, newest first) | PLATFORM_ADMIN |
|
||
| `GET` | `/api/admin/broadcasts/recipient-count?userIds=` (`{count}`; no `userIds` = all) | PLATFORM_ADMIN |
|
||
| `POST` | `/api/admin/broadcasts` (`{subjectEn, subjectDe, bodyEn, bodyDe, userIds}`; `201 {recipientCount}`; `409 NO_RECIPIENTS`) | PLATFORM_ADMIN |
|
||
| `POST` | `/api/admin/broadcasts/test` (same body; `204`) | PLATFORM_ADMIN |
|
||
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
||
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||
| `PUT` | `/api/users/settings/owner-lists` (`{enabled}`; `204`; opt-in to be shown by name in owner and user lists, only together with the public profile) | access token |
|
||
| `GET` | `/api/stats/users?key=TOTAL_USERS\|STEAM_LINKED&page=` (`{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`; never an id; not cached) | access token |
|
||
| `GET` | `/api/stats/public` | public |
|
||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||
|
||
Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`).
|
||
|
||
## Tech stack
|
||
|
||
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
|
||
- PostgreSQL 17, Flyway migrations
|
||
- JJWT with Ed25519 keys
|
||
- Spring Boot Actuator + Micrometer (Prometheus)
|
||
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
|
||
- CI: Forgejo Actions, multi-stage Docker image
|
||
|
||
## Architecture
|
||
|
||
Package by feature (`modules/login`, `modules/registration`, `modules/user`, ...), and inside each feature three
|
||
layers:
|
||
|
||
| Layer | Package | Rule |
|
||
|---|---|---|
|
||
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. |
|
||
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. |
|
||
| Persistence | `persistence`, `repo` | JPA entities, Spring Data repositories, `EntityMapper`; `repo` holds `JdbcTemplate` queries. Entities never reach the API layer. |
|
||
|
||
`user` owns the tables `users` and `user_settings` through the one JPA entity `UserEntity`. `login` and
|
||
`registration` read and write `users` with `JdbcTemplate` (refresh tokens stay JPA). Every query for "a user who
|
||
may log in and is shown to others" uses `shared/account/ActiveUser`.
|
||
A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the
|
||
display name and the settings. A new setting needs no new repository method.
|
||
Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write,
|
||
`activeUsers` (the filter's "blocked or deleted, and when did the role change?" check per request) after 30 seconds.
|
||
Every write evicts what it changes. A request that read the database just before a block or delete can put the old answer back, so the short
|
||
TTL is the real limit: a blocked or deleted user, or a token from before a role change, works for up to 30 s.
|
||
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
|
||
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
|
||
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
|
||
through `CoreAccountService`.
|
||
`admin` (users page, audit) works the same way. It deletes through the port `shared/account/AccountDeleter`, and
|
||
the login module writes "view as" to the audit table through `shared/audit/AdminAuditLog`, because modules never
|
||
use each other.
|
||
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
|
||
`shared/audit/AdminAuditLog`.
|
||
`news` (what's new entries and their admin API) is built the same way as `hint`; the seen stamp is a column of
|
||
`user_settings`, read and written by SQL in its own repo.
|
||
`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the
|
||
event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port
|
||
`shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set.
|
||
Broadcasts (`BroadcastService`, `mail_broadcast`) queue one row per recipient; the same job sends them after the
|
||
account mails and under the daily limit.
|
||
|
||
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
|
||
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
||
classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in
|
||
`auth_service.shared.test`. Integration and persistence tests share one PostgreSQL container (`TestPostgres`), and
|
||
the base classes carry every annotation, so each kind starts one Spring context. A test with its own annotations
|
||
starts another one.
|
||
|
||
**Mirrored in Core.** These files exist in both services with small differences; a change in one needs the same
|
||
change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`,
|
||
`Public` (Core also has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the
|
||
ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json`
|
||
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two).
|
||
|
||
### Login flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant B as Browser (Frontend)
|
||
participant A as Auth
|
||
participant C as Core
|
||
B->>A: POST /api/auth/login
|
||
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
||
B->>C: API call with tbp_access
|
||
C->>A: GET /.well-known/jwks.json (new kid only)
|
||
A-->>C: Public keys (Ed25519)
|
||
C-->>B: 200, or 401 when the access token expired
|
||
B->>A: POST /api/auth/refresh with tbp_refresh
|
||
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
||
B->>C: Same API call again
|
||
Note over B,A: View as (admin only)
|
||
B->>A: POST /api/auth/view-as {role} with tbp_refresh
|
||
A-->>B: New tbp_access (role claim) and tbp_refresh, active role stored for this login
|
||
```
|
||
|
||
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
||
can read them.
|
||
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them
|
||
again only for an unknown `kid`.
|
||
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
|
||
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
||
|
||
### Steam link flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant B as Browser (Frontend)
|
||
participant A as Auth
|
||
participant S as Steam
|
||
B->>A: POST /api/users/steam-link (logged in)
|
||
A-->>B: Steam login URL, Set-Cookie tbp_steam_link (state, 10 min)
|
||
B->>S: Sign in on steamcommunity.com
|
||
S-->>B: Redirect to /api/steam-link/callback?state=...&openid.*
|
||
B->>A: GET callback with tbp_steam_link
|
||
A->>S: check_authentication (openid.* sent back)
|
||
S-->>A: is_valid:true
|
||
A-->>B: Redirect to /settings?steam=linked
|
||
B->>A: POST /api/auth/refresh (new steamId claim for Core)
|
||
```
|
||
|
||
`SteamLinkService` checks the state against the binding cookie before it asks Steam anything, and uses each state
|
||
once. `SteamOpenIdService` checks mode, endpoint, `return_to` and the signed fields, then asks Steam.
|
||
`UserService.linkVerifiedSteamId` stores the ID and takes it from an account that only typed it in.
|
||
|
||
### Delete account flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant B as Browser (Frontend)
|
||
participant A as Auth
|
||
participant C as Core
|
||
B->>A: DELETE /api/users/me {password}
|
||
A->>A: users.deleted_at = now, revoke refresh tokens, mail row (one transaction)
|
||
A->>C: POST /internal/accounts/deleted (service token)
|
||
A-->>B: 204, both cookies cleared
|
||
Note over A: an old access token gets 401 within 30 s (activeUsers cache)
|
||
Note over A,C: nightly 04:15
|
||
A->>C: PUT /internal/accounts/deleted (all flagged users)
|
||
A->>C: PUT /internal/accounts/states (every user)
|
||
A->>C: POST /internal/accounts/purge (flagged 30+ days)
|
||
A->>A: DELETE users (only when Core answered and no dry run)
|
||
A->>C: POST /internal/accounts/prune (all user ids)
|
||
```
|
||
|
||
A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30
|
||
days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
|
||
|
||
### Mail flow
|
||
|
||
Each mail is a row in `mail_outbox`, written in the transaction of its event, so the event never fails because of
|
||
the mail server and no mail is lost when Auth restarts. The text is rendered then, in the user's language (`de`,
|
||
else English); the admin mail is German.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant B as Browser (Frontend)
|
||
participant A as Auth
|
||
participant M as SMTP (Hostinger)
|
||
B->>A: admin sends a broadcast
|
||
A->>A: mail_broadcast row + one outbox row per active user
|
||
B->>A: register / admin decides / admin blocks / delete account
|
||
A->>A: event + mail_outbox row (PENDING), one transaction
|
||
loop every minute (MailOutboxService)
|
||
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
|
||
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
|
||
end
|
||
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days
|
||
```
|
||
|
||
### Registration flow
|
||
|
||
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
|
||
sequenceDiagram
|
||
participant U as New user
|
||
participant A as Auth
|
||
participant D as Admin
|
||
U->>A: POST /api/registration/new
|
||
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"
|
||
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
|
||
A-->>U: mail "your account is active"
|
||
else denied
|
||
A->>A: status = DENIED, deleted_at = now (nightly cleanup after 30 days)
|
||
A-->>U: mail "your registration was denied"
|
||
end
|
||
```
|
||
|
||
### Password reset flow
|
||
|
||
The link is valid for 1 hour and works once: the reset is one `UPDATE ... WHERE password_reset_hash = ? AND expires > now
|
||
AND <active>` that also clears the hash. At most 3 reset mails per address and hour (over all IPs) and 5 requests per IP
|
||
per 15 minutes; failed confirms are limited to 10 per IP. Other logins keep their access token until it expires (up to
|
||
15 minutes); their refresh tokens end at once.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as User
|
||
participant A as Auth
|
||
participant D as Admin
|
||
U->>A: POST /api/auth/password-reset {email}
|
||
A->>A: active user: store token hash + expiry, one mail row
|
||
A-->>U: 204 (same for any address)
|
||
A-->>U: mail "reset your password" (link with token)
|
||
U->>A: POST /api/auth/password-reset/confirm {token, password}
|
||
A->>A: new password, hash cleared, all refresh tokens revoked
|
||
A-->>U: 204 and mail "your password was changed"
|
||
Note over D,A: POST /api/admin/users/{id}/password-reset sends the same mail (active users only) and writes the audit log
|
||
```
|
||
|
||
### Steam ID and block sync to Core
|
||
|
||
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
||
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
|
||
(`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
|
||
cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display
|
||
name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name:
|
||
`PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket:
|
||
`ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile
|
||
hides (#27). The call runs on the task executor, so neither the answer nor a database
|
||
connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant A as Auth
|
||
participant C as Core
|
||
A->>A: link / unlink / block / unblock / name change (commit)
|
||
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}
|
||
C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name
|
||
Note over A,C: nightly 04:15
|
||
A->>C: PUT /internal/accounts/states (every user)
|
||
```
|
||
|
||
## Run locally
|
||
|
||
### Local development without keys (profile `local`)
|
||
|
||
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
|
||
Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each
|
||
other, then start them in this order (Core also starts without Auth and finds it later):
|
||
|
||
1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
|
||
(IDE: active profile `local`). Port 8080.
|
||
2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090.
|
||
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
||
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
||
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
||
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
|
||
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
||
|
||
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
||
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
|
||
restart), log in again. Never use `local` on a server: the dev user's password is public. The
|
||
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until
|
||
the dev user accepts it on the admin page. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
|
||
|
||
### With your own keys (profile `dev`)
|
||
|
||
Needs Java 25, Docker and your own keys.
|
||
|
||
```bash
|
||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||
```
|
||
|
||
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
||
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
|
||
|
||
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core
|
||
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no
|
||
cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every
|
||
local port.
|
||
|
||
Configuration comes from environment variables:
|
||
|
||
| Variable | Purpose |
|
||
|---|---|
|
||
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
||
| `JWT_KID` | Key id written into the token header |
|
||
| `DB_PASSWORD` | Local PostgreSQL password |
|
||
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
||
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
||
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
||
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
|
||
| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
|
||
| `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host |
|
||
| `APP_MAIL_DAILY_LIMIT` | Mails per rolling 24 h, all kinds together (default 100, Hostinger's free mailbox; 1000 Business Starter, 3000 Business Standard / Premium). Broadcast rows keep 10 free |
|
||
|
||
Generate a keypair:
|
||
|
||
```bash
|
||
openssl genpkey -algorithm ed25519 -out private.pem
|
||
openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY
|
||
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
|
||
```
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
./mvnw clean verify
|
||
```
|
||
|
||
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
|
||
|
||
## Roadmap
|
||
|
||
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
|
||
|
||
## License
|
||
|
||
All rights reserved. The source is public for reading only.
|