Public Access
294 lines
18 KiB
Markdown
294 lines
18 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, signs JWTs |
|
||
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||
| ToBePlayed-Frontend | React single-page app |
|
||
|
||
## Features
|
||
|
||
- Registration with an invitation code, 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 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, 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'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. Until item 29 the tokens also carry the claims of before
|
||
item 26 (`userId`, `typ`), so an older Core still reads them. 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 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, 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`.
|
||
- **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`.
|
||
- **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 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/auth/login` | public, sets both cookies |
|
||
| `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=&page=&size=` (`{content, page, size, totalElements, totalPages}`) | PLATFORM_ADMIN |
|
||
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN |
|
||
| `DELETE` | `/api/admin/users/{id}` (`409 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 |
|
||
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | PLATFORM_ADMIN |
|
||
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
|
||
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `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`) | access token |
|
||
| `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}]}`: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||
| `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` | JPA entities, Spring Data repositories, `EntityMapper`. Entities never reach the API layer. |
|
||
|
||
`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`.
|
||
|
||
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`; all integration tests share one PostgreSQL container.
|
||
|
||
### 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
|
||
A->>C: POST /internal/accounts/deleted (service token)
|
||
A-->>B: 204, both cookies cleared
|
||
Note over A,C: nightly 04:15
|
||
A->>C: PUT /internal/accounts/deleted (all flagged users)
|
||
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).
|
||
|
||
## 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). The invite code for more
|
||
users is `local`. 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`, metrics: `/actuator/prometheus`.
|
||
|
||
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 |
|
||
| `AUTH_INVITATION_CODE` | Code required for registration |
|
||
| `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`) |
|
||
|
||
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
|
||
|
||
- Account deletion and data export.
|
||
|
||
## License
|
||
|
||
All rights reserved. The source is public for reading only.
|