Compare commits

...
17 Commits
Author SHA1 Message Date
lobo 4701cebece chore: remove unused dev compose and prometheus dependency
Build, Test & Push Auth Service / quality-check (push) Successful in 1m41s
Build, Test & Push Auth Service / docker (push) Successful in 1m40s
Prometheus is not in use. Only /actuator/health is exposed.
2026-10-14 15:22:20 +02:00
lobo c124d0e5d2 docs(readme): match readme to current code
Build, Test & Push Auth Service / quality-check (push) Successful in 1m4s
Build, Test & Push Auth Service / docker (push) Successful in 2m4s
Readme had drifted from the code. Update the repo list, endpoints,
JWKS and cookie rules, env vars, CI and dev data.
2026-10-14 14:51:40 +02:00
lobo c362e7047e test(cors): pin Access-Control-Max-Age 3600 on preflight
Build, Test & Push Auth Service / quality-check (push) Successful in 1m5s
Build, Test & Push Auth Service / docker (push) Successful in 1m32s
Refs #47
2026-10-12 09:25:49 +02:00
lobo 734ff2e07c feat(configuration): add request timing log
Build, Test & Push Auth Service / quality-check (push) Successful in 1m3s
Build, Test & Push Auth Service / docker (push) Successful in 1m33s
One INFO line per API request: method, handler pattern, status, ms.
No ids or query strings in the line. Refs #40.
2026-10-11 11:05:18 +02:00
lobo d842d9e865 feat(account): push public profile flag and sections to Core
Build, Test & Push Auth Service / quality-check (push) Successful in 1m4s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s
2026-10-06 16:22:22 +02:00
lobo d924c09953 feat(user): add owner-lists opt-in setting and user listing endpoint
Build, Test & Push Auth Service / quality-check (push) Successful in 1m5s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
2026-10-06 13:19:58 +02:00
lobo d9b816904a feat(dashboard): add up-next tile to saved dashboard layouts
Build, Test & Push Auth Service / quality-check (push) Successful in 1m25s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s
2026-10-06 09:06:51 +02:00
lobo 395b8e6f99 feat(user): add chart forms for TOP_GAMES, ACTIVITY, ACTIVITY_GAMES
Build, Test & Push Auth Service / quality-check (push) Successful in 1m0s
Build, Test & Push Auth Service / docker (push) Successful in 1m30s
2026-10-04 21:06:21 +02:00
lobo 365b69feb4 feat(admin): let admins change user roles
V32 adds role_changed_at and the role_change log. Role changes lock
the admin rows, so there is always one admin left; the last admin
cannot delete their own account. Login and refresh read the role
under the user lock. Tokens issued before a role change are refused.
2026-10-04 20:09:22 +02:00
lobo 3410cbc598 feat(settings): store chart form choice per stat group
Build, Test & Push Auth Service / quality-check (push) Successful in 1m0s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s
2026-10-04 10:43:02 +02:00
lobo 19463a1a4d feat(hint): target groups and automatic hints, V30
Build, Test & Push Auth Service / quality-check (push) Successful in 59s
Build, Test & Push Auth Service / docker (push) Successful in 1m34s
Hint targets combined with AND: without Steam, role and higher, new
users by registration_date, empty library flag. Four automatic hints
(NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE), on by default; the
admin turns each on or off (audited). Users snooze one for 7 days (X)
or hide it for good (tick), stored on the server. Hint text may use
**bold** and [text](url).
2026-10-02 14:36:49 +02:00
lobo 5a2552ddf6 feat(password): add reset and change password flows
Build, Test & Push Auth Service / quality-check (push) Successful in 53s
Build, Test & Push Auth Service / docker (push) Successful in 1m30s
Forgot password with a single-use reset link (1 hour, ACTIVE users
only, same answer for every address), change password (current session
stays, others end), admin reset mail with audit row, 'password
changed' mail. Login and refresh wait for a running password change.
2026-10-02 10:35:08 +02:00
lobo 6e7fb1e066 feat(account): send display name in account state to Core
Build, Test & Push Auth Service / quality-check (push) Successful in 49s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
The account state push carries displayName. It is published when the
name changes. Contract test updated.
2026-10-02 09:10:56 +02:00
lobo 1b7bafd9f0 feat(mail): send broadcasts to every active user
Remove the platform_mails opt-out. Broadcasts go to all active users.
V28 drops the column; the PUT /api/users/settings/platform-mails
endpoint and the footer line about turning mails off are gone.
2026-10-02 07:53:35 +02:00
lobo 0af76da9c7 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.
2026-10-01 16:20:51 +02:00
lobo 878ede914b feat(settings): reduce animations appearance flag
Build, Test & Push Auth Service / quality-check (push) Successful in 53s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
2026-10-01 15:09:25 +02:00
lobo a60011ce9f feat(mail): add broadcast mail for admins
Adds endpoints for PLATFORM_ADMIN to send mail to all active users or
picked ones. Broadcasts and account mails share the same outbox;
broadcasts take the rest of the daily limit after 10 mails are kept free
for account mail. Users can opt out via settings. History is kept in
mail_broadcast with recipient, sent and failed counts. The footer says
where to opt out.
2026-10-01 14:12:34 +02:00
189 changed files with 6303 additions and 413 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
# docker-compose.dev.yml
services:
# ─────────────────────────────────────────────
# LOCAL DATABASE FOR DEVELOPMENT
# ─────────────────────────────────────────────
tbp-db-local:
image: postgres:17-alpine
container_name: tbp_db_local
restart: unless-stopped
ports:
# Map the container port 5432 to your local machine port 5432
- "5432:5432"
environment:
- POSTGRES_DB=tbp_auth
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
# Use a local volume to persist data across container restarts
- tbp_local_db_data:/var/lib/postgresql/data
volumes:
tbp_local_db_data:
-5
View File
@@ -86,11 +86,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<!-- testing --> <!-- testing -->
<dependency> <dependency>
+151 -47
View File
@@ -1,40 +1,49 @@
# To Be Played – Auth Service # To Be Played – Auth Service
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the Identity service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
public profile, and it signs the JWTs that the Core service trusts. by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. 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: To Be Played is split into four repositories:
| Repository | Role | | Repository | Role |
|---|---| |---|---|
| **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs | | **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs | | ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app | | ToBePlayed-Frontend | React single-page app |
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
## Features ## 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). 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 - 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 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. 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 - 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 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. public key as a JWK. Auth publishes only the current key; Core finds it by `kid`. A rotation (new `JWT_KID`) ends
every access token signed with the old key at once. The frontend renews them with the refresh cookie.
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in - 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_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 `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. 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 - 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. frontend origin. A `Bearer` header still works for API clients and tests.
- User settings: display name (unique, case-insensitive), language, dark mode. - User settings: display name (unique, case-insensitive), language, dark mode, and the chart form per stat group
(`stat_charts`, V31): bars, pie, donut, line or list. `StatCharts` holds the allowed forms per group.
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam - 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 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 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 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. 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 - Appearance settings synced across devices: accent color, corner style, wide layout, reduce animations, background and
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. 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, - 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 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 look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
@@ -55,6 +64,14 @@ To Be Played is split into three repositories:
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in 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_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. 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 - **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 `@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); active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
@@ -63,8 +80,9 @@ To Be Played is split into three repositories:
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a 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. 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 - **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; Core accepts the token (`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then
until it expires, max 15 min; 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 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`. 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 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 right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
@@ -75,6 +93,13 @@ To Be Played is split into three repositories:
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. 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 "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`. 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 - **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" 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 sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
@@ -83,9 +108,15 @@ 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 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. the deploy get the page.
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link - **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 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". 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 - **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 `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 profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
@@ -98,7 +129,12 @@ To Be Played is split into three repositories:
| Method | Path | Auth | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `POST` | `/api/registration/new` | public | | `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/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/refresh` | refresh cookie, replaces both cookies |
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies | | `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
| `GET` | `/api/users/me` | access token | | `GET` | `/api/users/me` | access token |
@@ -106,44 +142,57 @@ 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 | | `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 | | `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 | | `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 | | `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}/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 | | `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 |
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token | | `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 | | `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 | | `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}`; `201 {id}`, `400 INVALID_HINT`) | 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/{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 | | `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
| `PUT` | `/api/news/seen` (`204`) | access token | | `PUT` | `/api/news/seen` (`204`) | access token |
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN | | `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | 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 | | `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/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token | | `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 | | `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/appearance` | access token |
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | 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/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 | | `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/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 | | `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 | | `GET` | `/api/public/profiles/{displayName}` | public |
| `PUT` | `/api/users/settings/stat-chart` (`{group, chart}`; `chart` null = back to the default; `204`; `400 VALIDATION_FAILED` for an unknown group or form) | access token |
| `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/stats/public` | public |
| `GET` | `/.well-known/jwks.json` (the current public key as a JWK, `Cache-Control: max-age=300`) | public |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | 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 ## Tech stack
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads - Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
- PostgreSQL 17, Flyway migrations - PostgreSQL 17, Flyway migrations
- JJWT with Ed25519 keys - JJWT with Ed25519 keys
- Spring Boot Actuator + Micrometer (Prometheus) - Spring Boot Actuator (`/actuator/health` only)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit - Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image - CI: Gitea Actions (`.gitea/workflows/publish-auth.yml`), multi-stage Docker image
## Architecture ## Architecture
@@ -162,9 +211,9 @@ 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 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. 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, Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write,
`activeUsers` (the filter's "blocked or deleted?" check per request) after 30 seconds. Every write evicts what it `activeUsers` (the filter's "blocked or deleted, and when did the role change?" check per request) after 30 seconds.
changes. A request that read the database just before a block or delete can put the old answer back, so the short 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 keeps a valid access token for up to 30 s. 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. `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. `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 `account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
@@ -179,6 +228,8 @@ use each other.
`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the `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 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. `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 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 violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
@@ -203,8 +254,8 @@ sequenceDiagram
B->>A: POST /api/auth/login B->>A: POST /api/auth/login
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth) A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
B->>C: API call with tbp_access B->>C: API call with tbp_access
C->>A: GET /.well-known/jwks.json (new kid only) C->>A: GET /.well-known/jwks.json (unknown kid only)
A-->>C: Public keys (Ed25519) A-->>C: Current public key (Ed25519, one JWK)
C-->>B: 200, or 401 when the access token expired C-->>B: 200, or 401 when the access token expired
B->>A: POST /api/auth/refresh with tbp_refresh B->>A: POST /api/auth/refresh with tbp_refresh
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
@@ -216,8 +267,8 @@ sequenceDiagram
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script 1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
can read them. can read them.
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them 2. Core verifies the access token with Auth's public keys only. `JwksController` publishes the current key only; Core
again only for an unknown `kid`. loads it again only for an unknown `kid`.
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again. 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. `RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
@@ -277,18 +328,27 @@ sequenceDiagram
participant B as Browser (Frontend) participant B as Browser (Frontend)
participant A as Auth participant A as Auth
participant M as SMTP (Hostinger) 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 B->>A: register / admin decides / admin blocks / delete account
A->>A: event + mail_outbox row (PENDING), one transaction A->>A: event + mail_outbox row (PENDING), one transaction
loop every minute (MailOutboxService) loop every minute (MailOutboxService)
A->>M: up to 20 due rows 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 M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
end end
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days Note over A: after 10 tries FAILED and a warning in the log, SENT rows are removed after 30 days
``` ```
### Registration flow ### 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. registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21.
```mermaid ```mermaid
@@ -297,10 +357,13 @@ sequenceDiagram
participant A as Auth participant A as Auth
participant D as Admin participant D as Admin
U->>A: POST /api/registration/new 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-->>D: mail "a new registration waits"
A-->>U: mail "we got your registration"
U->>A: login: 403 ACCOUNT_PENDING 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} D->>A: PUT /api/admin/users/{id}/decision {accepted}
alt accepted alt accepted
A->>A: status = ACTIVE A->>A: status = ACTIVE
@@ -311,20 +374,47 @@ sequenceDiagram
end 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 ### 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 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 loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
(`AccountStatePublisher`); the nightly cleanup sends every user. The call runs on the task executor, so neither the answer (`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
nor a database connection waits for Core; a failed call only warns. 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 ```mermaid
sequenceDiagram sequenceDiagram
participant A as Auth participant A as Auth
participant C as Core participant C as Core
A->>A: link / unlink / block / unblock (commit) A->>A: link / unlink / block / unblock / name change (commit)
A->>C: POST /internal/accounts/states {userId, steamId, blocked} A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}
C->>C: blocked_user, Steam ID in user_steam_sync 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 Note over A,C: nightly 04:15
A->>C: PUT /internal/accounts/states (every user) A->>C: PUT /internal/accounts/states (every user)
``` ```
@@ -343,7 +433,7 @@ other, then start them in this order (Core also starts without Auth and finds it
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173` 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 (`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`. 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 4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers. 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 In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
@@ -363,24 +453,31 @@ Needs Java 25, Docker and your own keys.
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the 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). 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 Auth has no `application-dev.yml`: any profile but `local` (also none) runs the same way and takes the keys from the
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no environment. `local` must not be combined with `prod`.
cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every
local port. Only `tbp_access` gets the domain from `APP_AUTH_COOKIE_DOMAIN` (blank = host-only; production sets the parent
domain, so Core gets it too). `tbp_refresh` (`Path=/api/auth`) and `tbp_steam_link` are always host-only. The cookies
are `Secure` (`app.auth.cookie-secure`, default `true`). Chrome and Firefox accept `Secure` cookies on
`http://localhost`; Safari drops them, so the `local` profile sets `cookie-secure: false`. With your own keys on Safari
set `APP_AUTH_COOKIE_SECURE=false`. Open the frontend on `localhost`, not `127.0.0.1`.
Configuration comes from environment variables: Configuration comes from environment variables:
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) | | `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 | | `JWT_KID` | Key id in the token header (default `tbp-auth-1`); Auth refuses to start when it is blank |
| `DB_PASSWORD` | Local PostgreSQL password | | `APP_AUTH_COOKIE_DOMAIN` | Domain of `tbp_access`, blank = host-only |
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the nightly cleanup deletes nothing (it still sends the account states to Core); the 7-day purge of unconfirmed registrations runs anyway |
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) | | `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_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`) | | `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 | | `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_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_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: Generate a keypair:
@@ -390,6 +487,13 @@ 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 openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
``` ```
### Request timing log
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
duration, e.g. `GET /api/users/me 200 12 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
that leaves the filter chain is logged as `500`.
## Tests ## Tests
```bash ```bash
@@ -15,7 +15,8 @@ public class CacheConfig {
// After write, not after access: an entry another path changed without an evict still ages out. // After write, not after access: an entry another path changed without an evict still ages out.
static final Duration DEFAULT_TTL = Duration.ofMinutes(10); static final Duration DEFAULT_TTL = Duration.ofMinutes(10);
// The real limit after a block or delete: a concurrent read can put the old answer back after the evict. // The real limit after a block, delete or role change: a concurrent read can put the old answer back after the
// evict.
static final Duration ACTIVE_USERS_TTL = Duration.ofSeconds(30); static final Duration ACTIVE_USERS_TTL = Duration.ofSeconds(30);
@Bean @Bean
@@ -0,0 +1,54 @@
package auth_service.configuration;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerMapping;
import java.io.IOException;
/**
* One INFO line per request: method, handler pattern, status, duration. The pattern (e.g. {@code /api/games/{appid}})
* stands for the path, so the line holds no ids, query, user or credentials. A request no handler took shows {@code -}.
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestTimingFilter extends OncePerRequestFilter {
private static final Logger LOG = LoggerFactory.getLogger(RequestTimingFilter.class);
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
long start = System.nanoTime();
int status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
try {
filterChain.doFilter(request, response);
status = response.getStatus();
} finally {
// An exception that leaves the chain ends as a 500; the container sets it only after this filter.
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
LOG.info("{} {} {} {} ms", request.getMethod(), pattern != null ? pattern : "-", status,
(System.nanoTime() - start) / 1_000_000);
}
}
/**
* The health check calls the actuator every 30 s; its lines would flood the log.
*/
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
return request.getRequestURI().startsWith("/actuator/");
}
}
@@ -25,8 +25,8 @@ import java.util.stream.Stream;
/** /**
* Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that * Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that
* verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user counts as * verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user, and a token
* not logged in. * issued before the last role change of its user, count as not logged in.
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -48,7 +48,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
.flatMap(Optional::stream) .flatMap(Optional::stream)
.map(jwtService::parseLoginToken) .map(jwtService::parseLoginToken)
.flatMap(Optional::stream) .flatMap(Optional::stream)
.filter(each -> activeUserCheck.isActive(each.getIdAsLong())) .filter(each -> activeUserCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
.findFirst(); .findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) { if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
@@ -19,6 +19,7 @@ import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
@@ -65,15 +66,17 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
} }
@Override @Override
public String generateToken(String userId, Long steamId, Role role) { public String generateToken(String userId, Long steamId, Role role, Instant notBefore) {
long now = System.currentTimeMillis();
long issuedAt = notBefore == null ? now : Math.max(now, notBefore.toEpochMilli());
var builder = Jwts.builder() var builder = Jwts.builder()
.header().keyId(jwtConfig.getKid()).and() .header().keyId(jwtConfig.getKid()).and()
.issuer(ISSUER) .issuer(ISSUER)
.audience().add(LOGIN_AUDIENCE).and() .audience().add(LOGIN_AUDIENCE).and()
.subject(userId) .subject(userId)
.claim(ROLE_CLAIM, role.name()) .claim(ROLE_CLAIM, role.name())
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(issuedAt))
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis())); .expiration(new Date(now + sessionProperties.getAccessTokenTtl().toMillis()));
if (steamId != null) { if (steamId != null) {
builder.claim("steamId", steamId); builder.claim("steamId", steamId);
@@ -139,7 +142,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class),
Role.parse(claims.get(ROLE_CLAIM, String.class)))); Role.parse(claims.get(ROLE_CLAIM, String.class)),
claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant()));
} catch (Exception _) { } catch (Exception _) {
return Optional.empty(); return Optional.empty();
} }
@@ -97,6 +97,8 @@ public class SecurityConfig {
// 401, not Spring's default 403: the frontend renews the access cookie only on a 401. // 401, not Spring's default 403: the frontend renews the access cookie only on a 401.
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Under /api/auth only for the refresh cookie; it changes the password of the logged-in user.
.requestMatchers(HttpMethod.POST, "/api/auth/password").authenticated()
.requestMatchers(WHITE_LIST_URLS).permitAll() .requestMatchers(WHITE_LIST_URLS).permitAll()
// The active role: an admin who views as USER gets 403 here. // The active role: an admin who views as USER gets 403 here.
.requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN") .requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN")
@@ -1,15 +1,23 @@
package auth_service.modules.account.api; package auth_service.modules.account.api;
import auth_service.modules.account.api.dto.request.ChangePasswordRequest;
import auth_service.modules.account.api.dto.request.DeleteAccountRequest; import auth_service.modules.account.api.dto.request.DeleteAccountRequest;
import auth_service.modules.account.api.dto.request.ForgotPasswordRequest;
import auth_service.modules.account.api.dto.request.ResetPasswordRequest;
import auth_service.modules.account.service.AccountService; import auth_service.modules.account.service.AccountService;
import auth_service.modules.account.service.PasswordResetService;
import auth_service.shared.api.AuthCookies; import auth_service.shared.api.AuthCookies;
import auth_service.shared.api.ClientIp;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -18,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AccountController { public class AccountController {
private final AccountService accountService; private final AccountService accountService;
private final PasswordResetService passwordResetService;
private final AuthCookies authCookies; private final AuthCookies authCookies;
@DeleteMapping(value = "/api/users/me") @DeleteMapping(value = "/api/users/me")
@@ -28,4 +37,29 @@ public class AccountController {
authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString())); authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
return ResponseEntity.noContent().headers(headers).build(); return ResponseEntity.noContent().headers(headers).build();
} }
@PostMapping(value = "/api/auth/password-reset", consumes = "application/json")
public ResponseEntity<Void> requestReset(@Valid @RequestBody ForgotPasswordRequest request,
HttpServletRequest httpRequest) {
passwordResetService.requestReset(request.email(), ClientIp.of(httpRequest));
return ResponseEntity.noContent().build();
}
@PostMapping(value = "/api/auth/password-reset/confirm", consumes = "application/json")
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request,
HttpServletRequest httpRequest) {
passwordResetService.resetPassword(request.token(), request.password(), ClientIp.of(httpRequest));
return ResponseEntity.noContent().build();
}
// Under /api/auth only because the refresh cookie has that path; it tells which login stays.
@PostMapping(value = "/api/auth/password", consumes = "application/json")
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ChangePasswordRequest request,
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false)
String refreshToken) {
accountService.changePassword(principal.getIdAsLong(), request.currentPassword(), request.newPassword(),
refreshToken);
return ResponseEntity.noContent().build();
}
} }
@@ -1,7 +1,9 @@
package auth_service.modules.account.api; package auth_service.modules.account.api;
import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException; import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.PasswordResetInvalidException;
import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.shared.api.dto.response.ErrorResponse;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -22,8 +24,20 @@ public class AccountExceptionController {
.body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage())); .body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage()));
} }
@ExceptionHandler(TooManyDeleteAttemptsException.class) @ExceptionHandler(PasswordResetInvalidException.class)
public ResponseEntity<ErrorResponse> clientError(TooManyDeleteAttemptsException ex) { public ResponseEntity<ErrorResponse> clientError(PasswordResetInvalidException ex) {
return ResponseEntity.status(HttpStatus.GONE).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("RESET_INVALID", ex.getMessage()));
}
@ExceptionHandler(LastAdminException.class)
public ResponseEntity<ErrorResponse> clientError(LastAdminException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("LAST_ADMIN", ex.getMessage()));
}
@ExceptionHandler(TooManyAttemptsException.class)
public ResponseEntity<ErrorResponse> clientError(TooManyAttemptsException ex) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds())) .header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -0,0 +1,25 @@
package auth_service.modules.account.api.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.nio.charset.StandardCharsets;
public record ChangePasswordRequest(@NotBlank String currentPassword, @NotBlank @Size(min = 8) String newPassword) {
/**
* BCrypt uses only the first 72 bytes and refuses a longer password.
*/
@AssertTrue(message = "The password may have at most 72 bytes")
@JsonIgnore
public boolean isPasswordShortEnough() {
return newPassword == null || newPassword.getBytes(StandardCharsets.UTF_8).length <= 72;
}
@Override
public String toString() {
return "ChangePasswordRequest[]";
}
}
@@ -0,0 +1,13 @@
package auth_service.modules.account.api.dto.request;
import auth_service.shared.account.EmailAddress;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ForgotPasswordRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email) {
public ForgotPasswordRequest {
email = EmailAddress.normalize(email);
}
}
@@ -0,0 +1,25 @@
package auth_service.modules.account.api.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.nio.charset.StandardCharsets;
public record ResetPasswordRequest(@NotBlank @Size(max = 64) String token, @NotBlank @Size(min = 8) String password) {
/**
* BCrypt uses only the first 72 bytes and refuses a longer password.
*/
@AssertTrue(message = "The password may have at most 72 bytes")
@JsonIgnore
public boolean isPasswordShortEnough() {
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
}
@Override
public String toString() {
return "ResetPasswordRequest[]";
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.account.exception;
public class LastAdminException extends RuntimeException {
public LastAdminException() {
super("The last admin cannot delete the own account");
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.account.exception;
public class PasswordResetInvalidException extends RuntimeException {
public PasswordResetInvalidException() {
super("The reset link is unknown, used or expired.");
}
}
@@ -0,0 +1,13 @@
package auth_service.modules.account.exception;
import lombok.Getter;
@Getter
public class TooManyAttemptsException extends RuntimeException {
private final long retryAfterSeconds;
public TooManyAttemptsException(long retryAfterSeconds) {
super("Too many attempts. Try again later.");
this.retryAfterSeconds = retryAfterSeconds;
}
}
@@ -1,13 +0,0 @@
package auth_service.modules.account.exception;
import lombok.Getter;
@Getter
public class TooManyDeleteAttemptsException extends RuntimeException {
private final long retryAfterSeconds;
public TooManyDeleteAttemptsException(long retryAfterSeconds) {
super("Too many wrong passwords. Try again later.");
this.retryAfterSeconds = retryAfterSeconds;
}
}
@@ -1,7 +1,17 @@
package auth_service.modules.account.model; package auth_service.modules.account.model;
import java.time.Instant;
import java.util.List;
/** /**
* One user as Core copies it. * One user as Core copies it.
*
* @param roleChangedAt null when the role never changed on the admin page
* @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser}
* @param publicProfile may be followed in Core, see {@link auth_service.shared.account.PublicUser}
* @param profileSections the profile parts the user shows ({@link auth_service.shared.account.ProfileSections}),
* null without a settings row
*/ */
public record CoreAccountState(long userId, Long steamId, boolean blocked) { public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
boolean listed, boolean publicProfile, List<String> profileSections) {
} }
@@ -1,6 +1,12 @@
package auth_service.modules.account.repo; package auth_service.modules.account.repo;
import auth_service.modules.account.model.CoreAccountState; import auth_service.modules.account.model.CoreAccountState;
import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.account.ActiveUser;
import auth_service.shared.account.EmailAddress;
import auth_service.shared.account.ListedUser;
import auth_service.shared.account.ProfileSections;
import auth_service.shared.account.PublicUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
@@ -8,8 +14,12 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.sql.Array;
import java.sql.SQLException;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -49,6 +59,54 @@ public class AccountRepository {
return flagged; return flagged;
} }
public Optional<Long> findActiveIdByEmail(String email) {
return jdbc.queryForList("SELECT u.id FROM users u WHERE u.email = ? AND " + ActiveUser.of("u"), Long.class,
EmailAddress.normalize(email)).stream().findFirst();
}
/**
* Replaces an older reset link of the user.
*
* @return false when the user is not active
*/
public boolean storePasswordReset(long userId, String tokenHash, Instant expiresAt) {
return jdbc.update("""
UPDATE users u SET password_reset_hash = ?, password_reset_expires_at = ?
WHERE u.id = ? AND\s""" + ActiveUser.of("u"), tokenHash, Timestamp.from(expiresAt), userId) == 1;
}
/**
* Sets the password and uses up the link in one statement, so a link works once.
*
* @return the id of the user, empty when the link is unknown, used, expired or the user is not active
*/
public Optional<Long> resetPassword(String tokenHash, String passwordHash, Instant now) {
return jdbc.queryForList("""
UPDATE users u SET password = ?, password_reset_hash = NULL, password_reset_expires_at = NULL
WHERE u.password_reset_hash = ? AND u.password_reset_expires_at > ? AND\s""" + ActiveUser.of("u")
+ " RETURNING u.id", Long.class, passwordHash, tokenHash, Timestamp.from(now)).stream().findFirst();
}
/**
* Also ends an open reset link.
*/
public void updatePassword(long userId, String passwordHash) {
jdbc.update("""
UPDATE users SET password = ?, password_reset_hash = NULL, password_reset_expires_at = NULL
WHERE id = ?""", passwordHash, userId);
}
/**
* Ends every login of the user but the one of keepTokenHash; null ends all. IS DISTINCT FROM, because for an
* unknown hash the subquery is NULL and a plain comparison would revoke nothing.
*/
public int revokeSessions(long userId, String keepTokenHash, Instant now) {
return jdbc.update("""
UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL
AND family_id IS DISTINCT FROM (SELECT family_id FROM refresh_tokens WHERE token_hash = ? AND user_id = ?)""",
Timestamp.from(now), userId, keepTokenHash, userId);
}
public List<Long> findFlaggedIds() { public List<Long> findFlaggedIds() {
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class); return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class);
} }
@@ -59,17 +117,42 @@ public class AccountRepository {
} }
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) -> private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), !rs.getBoolean("enabled")); new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
!rs.getBoolean("enabled"),
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(),
rs.getBoolean("listed"), rs.getBoolean("public_profile"), sections(rs.getArray("profile_tiles")));
// Stored as "ID:SIZE", see PublicProfileRepository.tile.
private static List<String> sections(Array storedTiles) throws SQLException {
if (storedTiles == null) {
return null;
}
return ProfileSections.of(Arrays.stream((String[]) storedTiles.getArray())
.map(stored -> stored.replaceFirst(":[^:]*$", ""))
.toList());
}
private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, "
+ "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed, "
+ "COALESCE(" + PublicUser.of("u", "s") + ", FALSE) AS public_profile, s.profile_tiles "
+ "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id";
public Optional<CoreAccountState> findState(long userId) { public Optional<CoreAccountState> findState(long userId) {
return jdbc.query("SELECT id, steam_id, enabled FROM users WHERE id = ?", STATE_ROW, userId).stream().findFirst(); return jdbc.query(STATE_SELECT + " WHERE u.id = ?", STATE_ROW, userId).stream().findFirst();
} }
/** /**
* Every user, also the flagged ones. * Every user, also the flagged ones.
*/ */
public List<CoreAccountState> findAllStates() { public List<CoreAccountState> findAllStates() {
return jdbc.query("SELECT id, steam_id, enabled FROM users ORDER BY id", STATE_ROW); return jdbc.query(STATE_SELECT + " ORDER BY u.id", STATE_ROW);
}
/**
* Locks the active admins until the transaction ends, see {@link ActiveAdmins}.
*/
public List<Long> lockActiveAdmins() {
return jdbc.queryForList(ActiveAdmins.LOCK, Long.class);
} }
/** /**
@@ -88,4 +171,14 @@ public class AccountRepository {
} }
return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds)); return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds));
} }
/**
* Settings go by their ON DELETE CASCADE. registration_date is a naive UTC timestamp. A row whose resent link
* is still valid stays.
*/
public int deleteUnconfirmedBefore(LocalDateTime cutoff) {
return jdbc.update("""
DELETE FROM users WHERE status = 'UNCONFIRMED' AND registration_date < ?
AND (email_confirmation_expires_at IS NULL OR email_confirmation_expires_at < now())""", cutoff);
}
} }
@@ -7,8 +7,10 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/** /**
@@ -20,6 +22,7 @@ import java.util.List;
public class AccountCleanupService { public class AccountCleanupService {
static final String ZONE = "Europe/Berlin"; static final String ZONE = "Europe/Berlin";
static final Duration UNCONFIRMED_RETENTION = Duration.ofDays(7);
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
@@ -37,6 +40,7 @@ public class AccountCleanupService {
@Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE) @Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE)
public void runNightly() { public void runNightly() {
purgeUnconfirmed();
try { try {
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds()); coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
} catch (RuntimeException e) { } catch (RuntimeException e) {
@@ -48,6 +52,19 @@ public class AccountCleanupService {
pruneOrphans(); pruneOrphans();
} }
// Not gated by dryRun: an unconfirmed row never was an account and has nothing in Core.
private void purgeUnconfirmed() {
try {
int deleted = accountRepository.deleteUnconfirmedBefore(
LocalDateTime.now(Clock.systemUTC()).minus(UNCONFIRMED_RETENTION));
if (deleted > 0) {
log.info("Account cleanup: deleted {} unconfirmed registrations", deleted);
}
} catch (RuntimeException e) {
log.warn("Account cleanup: purge of unconfirmed registrations failed: {}", e.getMessage());
}
}
private void sendStates() { private void sendStates() {
try { try {
coreAccountService.replaceStates(accountRepository.findAllStates()); coreAccountService.replaceStates(accountRepository.findAllStates());
@@ -1,11 +1,14 @@
package auth_service.modules.account.service; package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException; import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository; import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountDeleter;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.AttemptLimiter; import auth_service.shared.security.AttemptLimiter;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.security.RequiresRole; import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -36,18 +39,20 @@ public class AccountService implements AccountDeleter {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final AccountMailer accountMailer; private final AccountMailer accountMailer;
private final AccountStatePublisher accountStatePublisher;
private final TransactionOperations transactions; private final TransactionOperations transactions;
private final Duration retention; private final Duration retention;
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK); private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder, public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
CoreAccountService coreAccountService, AccountMailer accountMailer, CoreAccountService coreAccountService, AccountMailer accountMailer,
TransactionOperations transactions, AccountStatePublisher accountStatePublisher, TransactionOperations transactions,
@Value("${app.account-cleanup.retention}") Duration retention) { @Value("${app.account-cleanup.retention}") Duration retention) {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.coreAccountService = coreAccountService; this.coreAccountService = coreAccountService;
this.accountMailer = accountMailer; this.accountMailer = accountMailer;
this.accountStatePublisher = accountStatePublisher;
this.transactions = transactions; this.transactions = transactions;
this.retention = retention; this.retention = retention;
} }
@@ -59,23 +64,34 @@ public class AccountService implements AccountDeleter {
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"), @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
@CacheEvict(value = "publicStats", allEntries = true)}) @CacheEvict(value = "publicStats", allEntries = true)})
public void deleteAccount(long userId, String password) { public void deleteAccount(long userId, String password) {
String key = String.valueOf(userId); String key = acquireAttempt(userId);
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
throw new TooManyDeleteAttemptsException(LOCK.toSeconds());
}
Optional<String> hash = accountRepository.findPasswordHashOfActive(userId); Optional<String> hash = accountRepository.findPasswordHashOfActive(userId);
if (hash.isEmpty()) { if (hash.isEmpty()) {
// Already flagged, e.g. a second click: the answer is the same. // Already flagged, e.g. a second click: the answer is the same.
attemptLimiter.release(key); attemptLimiter.release(key);
return; return;
} }
if (!passwordEncoder.matches(password, hash.get())) { checkPassword(key, password, hash.get());
throw new InvalidPasswordException();
}
attemptLimiter.reset(key);
flagAndTellCore(userId); flagAndTellCore(userId);
} }
/**
* Keeps the login of refreshToken (may be null: then all logins end) and ends every other one.
*/
public void changePassword(long userId, String currentPassword, String newPassword, String refreshToken) {
String key = acquireAttempt(userId);
checkPassword(key, currentPassword,
accountRepository.findPasswordHashOfActive(userId).orElseThrow(InvalidPasswordException::new));
String hash = passwordEncoder.encode(newPassword);
transactions.executeWithoutResult(status -> {
accountRepository.updatePassword(userId, hash);
accountRepository.revokeSessions(userId, refreshToken == null ? null : OpaqueToken.hash(refreshToken),
Instant.now());
accountMailer.passwordChanged(userId);
});
log.info("Password of user {} changed", userId);
}
@Override @Override
@RequiresRole(Role.PLATFORM_ADMIN) @RequiresRole(Role.PLATFORM_ADMIN)
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"), @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
@@ -84,10 +100,29 @@ public class AccountService implements AccountDeleter {
return flagAndTellCore(userId); return flagAndTellCore(userId);
} }
// Delete account and change password share the limit: both guess the same password.
private String acquireAttempt(long userId) {
String key = String.valueOf(userId);
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
throw new TooManyAttemptsException(LOCK.toSeconds());
}
return key;
}
private void checkPassword(String key, String password, String hash) {
if (!passwordEncoder.matches(password, hash)) {
throw new InvalidPasswordException();
}
attemptLimiter.reset(key);
}
// The flag and the mail commit together, and before Core is called. // The flag and the mail commit together, and before Core is called.
private boolean flagAndTellCore(long userId) { private boolean flagAndTellCore(long userId) {
Instant now = Instant.now(); Instant now = Instant.now();
boolean flagged = Boolean.TRUE.equals(transactions.execute(status -> { boolean flagged = Boolean.TRUE.equals(transactions.execute(status -> {
if (accountRepository.lockActiveAdmins().equals(List.of(userId))) {
throw new LastAdminException();
}
if (accountRepository.flagDeleted(userId, now) == 0) { if (accountRepository.flagDeleted(userId, now) == 0) {
return false; return false;
} }
@@ -101,8 +136,10 @@ public class AccountService implements AccountDeleter {
try { try {
coreAccountService.markDeleted(List.of(userId)); coreAccountService.markDeleted(List.of(userId));
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId, log.warn("Core did not take the delete of user {}, the state push tries again: {}", userId,
e.getMessage()); e.getMessage());
// The push carries listed=false and is retried; the nightly cleanup stays the last fallback.
accountStatePublisher.publish(userId);
} }
return true; return true;
} }
@@ -4,30 +4,46 @@ import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.account.AccountStatePublisher;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
/** /**
* Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core * Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core
* (up to 13 s) would keep it from the pool. * (up to 13 s) would keep it from the pool. A failed send is tried again later; the scheduler thread only hands the
* send back to the executor. Retries live in memory, so the nightly cleanup stays the fallback.
*/ */
@Service @Service
@Slf4j @Slf4j
public class CoreAccountStatePublisher implements AccountStatePublisher { public class CoreAccountStatePublisher implements AccountStatePublisher {
static final List<Duration> RETRY_DELAYS = List.of(Duration.ofSeconds(10), Duration.ofMinutes(1),
Duration.ofMinutes(5));
private static final int LOCK_STRIPES = 64;
private final Object[] sendLocks = new Object[LOCK_STRIPES];
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final Executor executor; private final Executor executor;
private final TaskScheduler scheduler;
public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService, public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService,
@Qualifier("applicationTaskExecutor") Executor executor) { @Qualifier("applicationTaskExecutor") Executor executor,
@Qualifier("taskScheduler") TaskScheduler scheduler) {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.coreAccountService = coreAccountService; this.coreAccountService = coreAccountService;
this.executor = executor; this.executor = executor;
this.scheduler = scheduler;
for (int i = 0; i < LOCK_STRIPES; i++) {
sendLocks[i] = new Object();
}
} }
@Override @Override
@@ -36,20 +52,30 @@ public class CoreAccountStatePublisher implements AccountStatePublisher {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override @Override
public void afterCommit() { public void afterCommit() {
executor.execute(() -> send(userId)); executor.execute(() -> send(userId, 0));
} }
}); });
} else { } else {
executor.execute(() -> send(userId)); executor.execute(() -> send(userId, 0));
} }
} }
private void send(long userId) { private void send(long userId, int attempt) {
try { try {
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state))); // Read and send under one lock per user: the send that runs last carries the newest state.
synchronized (sendLocks[(int) (userId % LOCK_STRIPES)]) {
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state)));
}
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId, if (attempt < RETRY_DELAYS.size()) {
e.getMessage()); log.warn("Core did not take the state of user {} (try {}), trying again later: {}", userId,
attempt + 1, e.getMessage());
scheduler.schedule(() -> executor.execute(() -> send(userId, attempt + 1)),
Instant.now().plus(RETRY_DELAYS.get(attempt)));
} else {
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
e.getMessage());
}
} }
} }
} }
@@ -0,0 +1,92 @@
package auth_service.modules.account.service;
import auth_service.modules.account.exception.PasswordResetInvalidException;
import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.PasswordResetSender;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.AttemptLimiter;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.security.Public;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
/**
* Forgot password: a mail with a link that sets a new password once. Only active users get a working link. The raw
* token and the address are never logged.
*/
@Service
@Public
@Slf4j
@RequiredArgsConstructor
public class PasswordResetService implements PasswordResetSender {
static final Duration RESET_TTL = Duration.ofHours(1);
static final Duration WINDOW = Duration.ofMinutes(15);
static final int MAX_REQUESTS_PER_IP = 5;
static final int MAX_FAILURES_PER_IP = 10;
static final int MAX_MAILS_PER_ADDRESS = 3;
static final Duration MAIL_WINDOW = Duration.ofHours(1);
private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
private final AccountMailer accountMailer;
private final AttemptLimiter requests = new AttemptLimiter(WINDOW);
private final AttemptLimiter failures = new AttemptLimiter(WINDOW);
private final AttemptLimiter mails = new AttemptLimiter(MAIL_WINDOW);
/**
* The same answer for a known and an unknown address. Over the mail limit of an address nothing is sent, also
* silently.
*/
@Transactional
public void requestReset(String email, String clientIp) {
if (!requests.tryAcquire(clientIp, MAX_REQUESTS_PER_IP)) {
throw new TooManyAttemptsException(WINDOW.toSeconds());
}
if (!mails.tryAcquire(email, MAX_MAILS_PER_ADDRESS)) {
return;
}
accountRepository.findActiveIdByEmail(email).ifPresent(this::sendResetMail);
}
/**
* Sets the password and ends every login of the user.
*/
@Transactional
public void resetPassword(String rawToken, String newPassword, String clientIp) {
if (!failures.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) {
throw new TooManyAttemptsException(WINDOW.toSeconds());
}
long userId = accountRepository.resetPassword(OpaqueToken.hash(rawToken), passwordEncoder.encode(newPassword),
Instant.now()).orElseThrow(PasswordResetInvalidException::new);
failures.release(clientIp);
accountRepository.revokeSessions(userId, null, Instant.now());
accountMailer.passwordChanged(userId);
log.info("Password of user {} reset by mail link", userId);
}
@Override
@RequiresRole(Role.PLATFORM_ADMIN)
@Transactional
public boolean sendByAdmin(long userId) {
return sendResetMail(userId);
}
private boolean sendResetMail(long userId) {
String token = OpaqueToken.generate();
if (!accountRepository.storePasswordReset(userId, OpaqueToken.hash(token), Instant.now().plus(RESET_TTL))) {
return false;
}
accountMailer.passwordReset(userId, token);
return true;
}
}
@@ -2,12 +2,17 @@ package auth_service.modules.admin.api;
import auth_service.modules.admin.api.dto.request.BlockRequest; import auth_service.modules.admin.api.dto.request.BlockRequest;
import auth_service.modules.admin.api.dto.request.DecisionRequest; import auth_service.modules.admin.api.dto.request.DecisionRequest;
import auth_service.modules.admin.api.dto.request.RoleChangeRequest;
import auth_service.modules.admin.api.dto.response.AdminUserPageResponse; import auth_service.modules.admin.api.dto.response.AdminUserPageResponse;
import auth_service.modules.admin.api.dto.response.AdminUserResponse; import auth_service.modules.admin.api.dto.response.AdminUserResponse;
import auth_service.modules.admin.api.dto.response.PendingCountResponse; import auth_service.modules.admin.api.dto.response.PendingCountResponse;
import auth_service.modules.admin.api.dto.response.RoleChangeListResponse;
import auth_service.modules.admin.api.dto.response.RoleChangeResponse;
import auth_service.modules.admin.model.AdminUser; import auth_service.modules.admin.model.AdminUser;
import auth_service.modules.admin.model.AdminUserPage; import auth_service.modules.admin.model.AdminUserPage;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.service.AdminUserService; import auth_service.modules.admin.service.AdminUserService;
import auth_service.modules.admin.service.RoleChangeService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -16,6 +21,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -28,6 +34,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AdminUserController { public class AdminUserController {
private final AdminUserService adminUserService; private final AdminUserService adminUserService;
private final RoleChangeService roleChangeService;
@GetMapping @GetMapping
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query, public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
@@ -58,15 +65,47 @@ public class AdminUserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping("/{userId}/role")
public ResponseEntity<Void> changeRole(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
@Valid @RequestBody RoleChangeRequest request) {
roleChangeService.changeRole(principal.getIdAsLong(), userId, request.expectedRole(), request.role());
return ResponseEntity.noContent().build();
}
@GetMapping("/{userId}/role-changes")
public ResponseEntity<RoleChangeListResponse> findRoleChanges(@PathVariable long userId) {
return ResponseEntity.ok(new RoleChangeListResponse(roleChangeService.findChanges(userId).stream()
.map(AdminUserController::toApi).toList()));
}
@DeleteMapping(value = "/{userId}") @DeleteMapping(value = "/{userId}")
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) { public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
adminUserService.delete(principal.getIdAsLong(), userId); adminUserService.delete(principal.getIdAsLong(), userId);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@DeleteMapping(value = "/{userId}/unconfirmed")
public ResponseEntity<Void> deleteUnconfirmed(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable long userId) {
adminUserService.deleteUnconfirmed(principal.getIdAsLong(), userId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/password-reset")
public ResponseEntity<Void> sendPasswordReset(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable long userId) {
adminUserService.sendPasswordReset(principal.getIdAsLong(), userId);
return ResponseEntity.noContent().build();
}
private static AdminUserResponse toApi(AdminUser user) { private static AdminUserResponse toApi(AdminUser user) {
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(), return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(), user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(),
user.status()); user.status());
} }
private static RoleChangeResponse toApi(RoleChange change) {
return new RoleChangeResponse(change.actorUserId(), change.actorEmail(), change.oldRole().name(),
change.newRole().name(), change.changedAt());
}
} }
@@ -0,0 +1,7 @@
package auth_service.modules.admin.api.dto.request;
import auth_service.shared.security.Role;
import jakarta.validation.constraints.NotNull;
public record RoleChangeRequest(@NotNull Role role, @NotNull Role expectedRole) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.admin.api.dto.response;
import java.util.List;
public record RoleChangeListResponse(List<RoleChangeResponse> results) {
}
@@ -0,0 +1,10 @@
package auth_service.modules.admin.api.dto.response;
import java.time.Instant;
/**
* @param actorEmail null after the actor is purged
*/
public record RoleChangeResponse(long actorUserId, String actorEmail, String oldRole, String newRole,
Instant changedAt) {
}
@@ -5,7 +5,8 @@ import lombok.Getter;
@Getter @Getter
public class AdminActionRefusedException extends RuntimeException { public class AdminActionRefusedException extends RuntimeException {
/** /**
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING or TARGET_IS_PENDING * SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING, TARGET_IS_PENDING, TARGET_IS_UNCONFIRMED,
* NOT_UNCONFIRMED, NOT_ACTIVE, ACTOR_NOT_ADMIN, TARGET_IS_BLOCKED or ROLE_CHANGED
*/ */
private final String code; private final String code;
@@ -6,7 +6,7 @@ import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* @param status PENDING (registration waits for an admin), ACTIVE or DENIED * @param status UNCONFIRMED (address not confirmed yet), PENDING (registration waits for an admin), ACTIVE or DENIED
* @param deletedAt set while the account waits for the nightly cleanup * @param deletedAt set while the account waits for the nightly cleanup
*/ */
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate, public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
@@ -0,0 +1,11 @@
package auth_service.modules.admin.model;
import auth_service.shared.security.Role;
import java.time.Instant;
/**
* @param actorEmail null after the actor is purged
*/
public record RoleChange(long actorUserId, String actorEmail, Role oldRole, Role newRole, Instant changedAt) {
}
@@ -0,0 +1,12 @@
package auth_service.modules.admin.model;
import auth_service.shared.security.Role;
import java.time.Instant;
/**
* @param deleted flagged as deleted, or denied
* @param roleChangedAt null when the role never changed on the admin page
*/
public record RoleTarget(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
}
@@ -2,5 +2,5 @@ package auth_service.modules.admin.model;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
public record TargetState(Role role, boolean deleted, boolean pending) { public record TargetState(Role role, boolean deleted, boolean pending, boolean unconfirmed) {
} }
@@ -53,7 +53,7 @@ public class AdminUserRepository {
/** /**
* @param query part of the email or display name, any case; empty for all users * @param query part of the email or display name, any case; empty for all users
* @param status PENDING, ACTIVE or DENIED; empty for all * @param status UNCONFIRMED, PENDING, ACTIVE or DENIED; empty for all
*/ */
public List<AdminUser> findPage(String query, String status, int page, int size) { public List<AdminUser> findPage(String query, String status, int page, int size) {
Object[] match = matchArgs(query); Object[] match = matchArgs(query);
@@ -74,7 +74,7 @@ public class AdminUserRepository {
public Optional<TargetState> findState(long userId) { public Optional<TargetState> findState(long userId) {
return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?", return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?",
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null, (rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null,
"PENDING".equals(rs.getString("status"))), "PENDING".equals(rs.getString("status")), "UNCONFIRMED".equals(rs.getString("status"))),
userId).stream().findFirst(); userId).stream().findFirst();
} }
@@ -95,6 +95,15 @@ public class AdminUserRepository {
Timestamp.from(now), userId) > 0; Timestamp.from(now), userId) > 0;
} }
/**
* Hard delete, like the nightly purge: the address is free at once. Settings go by their ON DELETE CASCADE.
*
* @return false when the user is not unconfirmed (any more)
*/
public boolean deleteUnconfirmed(long userId) {
return jdbc.update("DELETE FROM users WHERE id = ? AND status = 'UNCONFIRMED'", userId) > 0;
}
/** /**
* Blocking also ends every login of the user. * Blocking also ends every login of the user.
* *
@@ -0,0 +1,68 @@
package auth_service.modules.admin.repo;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.model.RoleTarget;
import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* Plain SQL on users, because the user and login modules own the entities.
*/
@Repository
@RequiredArgsConstructor
public class RoleChangeRepository {
private final JdbcTemplate jdbc;
/**
* Locks the active admins until the transaction ends, see {@link ActiveAdmins}.
*/
public List<Long> lockActiveAdmins() {
return jdbc.queryForList(ActiveAdmins.LOCK, Long.class);
}
public Optional<RoleTarget> lockTarget(long userId) {
return jdbc.query("""
SELECT role, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status, role_changed_at
FROM users WHERE id = ? FOR UPDATE""",
(rs, row) -> new RoleTarget(Role.parse(rs.getString("role")), !rs.getBoolean("enabled"),
rs.getBoolean("deleted"), rs.getString("status"),
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant()),
userId).stream().findFirst();
}
/**
* Also clears "view as" of every open login, so the next refresh starts at the new role.
*/
public void updateRole(long userId, Role role, Instant changedAt) {
jdbc.update("UPDATE users SET role = ?, role_changed_at = ? WHERE id = ?", role.name(),
Timestamp.from(changedAt), userId);
jdbc.update("UPDATE refresh_tokens SET active_role = NULL WHERE user_id = ? AND revoked_at IS NULL", userId);
}
public void insertChange(long actorUserId, long targetUserId, Role oldRole, Role newRole, Instant changedAt) {
jdbc.update("""
INSERT INTO role_change (actor_user_id, target_user_id, old_role, new_role, changed_at)
VALUES (?, ?, ?, ?, ?)""", actorUserId, targetUserId, oldRole.name(), newRole.name(),
Timestamp.from(changedAt));
}
public List<RoleChange> findChanges(long targetUserId, int limit) {
return jdbc.query("""
SELECT c.actor_user_id, a.email, c.old_role, c.new_role, c.changed_at
FROM role_change c LEFT JOIN users a ON a.id = c.actor_user_id
WHERE c.target_user_id = ? ORDER BY c.changed_at DESC, c.id DESC LIMIT ?""",
(rs, row) -> new RoleChange(rs.getLong("actor_user_id"), rs.getString("email"),
Role.parse(rs.getString("old_role")), Role.parse(rs.getString("new_role")),
rs.getTimestamp("changed_at").toInstant()),
targetUserId, limit);
}
}
@@ -7,6 +7,7 @@ import auth_service.modules.admin.model.TargetState;
import auth_service.modules.admin.repo.AdminUserRepository; import auth_service.modules.admin.repo.AdminUserRepository;
import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountDeleter;
import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.account.PasswordResetSender;
import auth_service.shared.audit.AdminAuditLog; import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.RequiresRole; import auth_service.shared.security.RequiresRole;
@@ -33,6 +34,7 @@ public class AdminUserService {
private final AccountDeleter accountDeleter; private final AccountDeleter accountDeleter;
private final AccountStatePublisher accountStatePublisher; private final AccountStatePublisher accountStatePublisher;
private final AccountMailer accountMailer; private final AccountMailer accountMailer;
private final PasswordResetSender passwordResetSender;
public AdminUserPage findUsers(String query, String status, int page, int size) { public AdminUserPage findUsers(String query, String status, int page, int size) {
String search = query == null ? "" : query.trim(); String search = query == null ? "" : query.trim();
@@ -71,7 +73,7 @@ public class AdminUserService {
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"), @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
@CacheEvict(value = "publicStats", allEntries = true)}) @CacheEvict(value = "publicStats", allEntries = true)})
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) { public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
TargetState target = notPending(checkedTarget(actorUserId, targetUserId)); TargetState target = notARequest(checkedTarget(actorUserId, targetUserId));
if (target.deleted()) { if (target.deleted()) {
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted"); throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
} }
@@ -90,13 +92,40 @@ public class AdminUserService {
* transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag. * transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag.
*/ */
public void delete(long actorUserId, long targetUserId) { public void delete(long actorUserId, long targetUserId) {
notPending(checkedTarget(actorUserId, targetUserId)); notARequest(checkedTarget(actorUserId, targetUserId));
if (accountDeleter.deleteByAdmin(targetUserId)) { if (accountDeleter.deleteByAdmin(targetUserId)) {
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null); adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
log.info("Admin {} deleted user {}", actorUserId, targetUserId); log.info("Admin {} deleted user {}", actorUserId, targetUserId);
} }
} }
/**
* Only for a request whose address was never confirmed: a hard delete, no mail. The nightly purge does the same
* after 7 days.
*/
@Transactional
public void deleteUnconfirmed(long actorUserId, long targetUserId) {
if (!checkedTarget(actorUserId, targetUserId).unconfirmed()
|| !adminUserRepository.deleteUnconfirmed(targetUserId)) {
throw new AdminActionRefusedException("NOT_UNCONFIRMED", "The registration is confirmed already");
}
adminAuditLog.record(actorUserId, "DELETE_UNCONFIRMED", targetUserId, null);
log.info("Admin {} deleted the unconfirmed registration of user {}", actorUserId, targetUserId);
}
/**
* The same mail as "Forgot password". The admin never sees or sets a password.
*/
@Transactional
public void sendPasswordReset(long actorUserId, long targetUserId) {
checkedTarget(actorUserId, targetUserId);
if (!passwordResetSender.sendByAdmin(targetUserId)) {
throw new AdminActionRefusedException("NOT_ACTIVE", "Only an active account can get a reset mail");
}
adminAuditLog.record(actorUserId, "SEND_PASSWORD_RESET", targetUserId, null);
log.info("Admin {} sent a password reset to user {}", actorUserId, targetUserId);
}
private TargetState checkedTarget(long actorUserId, long targetUserId) { private TargetState checkedTarget(long actorUserId, long targetUserId) {
if (actorUserId == targetUserId) { if (actorUserId == targetUserId) {
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account"); throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account");
@@ -109,10 +138,13 @@ public class AdminUserService {
return target; return target;
} }
private static TargetState notPending(TargetState target) { private static TargetState notARequest(TargetState target) {
if (target.pending()) { if (target.pending()) {
throw new AdminActionRefusedException("TARGET_IS_PENDING", "Accept or deny the registration first"); throw new AdminActionRefusedException("TARGET_IS_PENDING", "Accept or deny the registration first");
} }
if (target.unconfirmed()) {
throw new AdminActionRefusedException("TARGET_IS_UNCONFIRMED", "Delete the unconfirmed registration instead");
}
return target; return target;
} }
} }
@@ -0,0 +1,92 @@
package auth_service.modules.admin.service;
import auth_service.modules.admin.exception.AdminActionRefusedException;
import auth_service.modules.admin.exception.TargetUserNotFoundException;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.model.RoleTarget;
import auth_service.modules.admin.repo.RoleChangeRepository;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
@Service
@RequiresRole(Role.PLATFORM_ADMIN)
@RequiredArgsConstructor
@Slf4j
public class RoleChangeService {
private static final int LOG_LIMIT = 50;
private final RoleChangeRepository roleChangeRepository;
private final AdminAuditLog adminAuditLog;
private final AccountStatePublisher accountStatePublisher;
/**
* Locks the active admins, then the target, so two admins never remove each other (see ActiveAdmins). The actor
* is never the target and must still be an admin, so at least one admin stays.
*
* @param expectedRole the role the admin saw; another current role is refused, so no change is lost
*/
@Transactional
@Caching(evict = @CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"))
public void changeRole(long actorUserId, long targetUserId, Role expectedRole, Role newRole) {
if (actorUserId == targetUserId) {
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot change their own role");
}
if (!roleChangeRepository.lockActiveAdmins().contains(actorUserId)) {
throw new AdminActionRefusedException("ACTOR_NOT_ADMIN", "You are no admin any more");
}
RoleTarget target = roleChangeRepository.lockTarget(targetUserId)
.orElseThrow(() -> new TargetUserNotFoundException(targetUserId));
checkChangeable(target);
if (target.role() != expectedRole) {
throw new AdminActionRefusedException("ROLE_CHANGED", "The role changed in the meantime");
}
if (target.role() == newRole) {
return;
}
Instant now = Instant.now();
// The iat is in whole seconds, so a token of the change second may be older than the change. Valid tokens
// start in the next second, and after the previous change if that was in the same second. New tokens get
// at least this iat (TokenGenerator).
Instant validFrom = now.truncatedTo(ChronoUnit.SECONDS).plusSeconds(1);
if (target.roleChangedAt() != null && !target.roleChangedAt().isBefore(validFrom)) {
validFrom = target.roleChangedAt().plusSeconds(1);
}
roleChangeRepository.updateRole(targetUserId, newRole, validFrom);
roleChangeRepository.insertChange(actorUserId, targetUserId, target.role(), newRole, now);
adminAuditLog.record(actorUserId, "ROLE_CHANGE", targetUserId, target.role() + ">" + newRole);
accountStatePublisher.publish(targetUserId);
log.info("Admin {} changed the role of user {} from {} to {}", actorUserId, targetUserId, target.role(), newRole);
}
public List<RoleChange> findChanges(long targetUserId) {
return roleChangeRepository.findChanges(targetUserId, LOG_LIMIT);
}
private static void checkChangeable(RoleTarget target) {
if (target.deleted()) {
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
}
if (target.blocked()) {
throw new AdminActionRefusedException("TARGET_IS_BLOCKED", "Unblock the account first");
}
if ("PENDING".equals(target.status())) {
throw new AdminActionRefusedException("TARGET_IS_PENDING", "Accept or deny the registration first");
}
if ("UNCONFIRMED".equals(target.status())) {
throw new AdminActionRefusedException("TARGET_IS_UNCONFIRMED", "Delete the unconfirmed registration instead");
}
}
}
@@ -1,11 +1,14 @@
package auth_service.modules.hint.api; package auth_service.modules.hint.api;
import auth_service.modules.hint.api.dto.request.AutoHintRequest;
import auth_service.modules.hint.api.dto.request.CreateHintRequest; import auth_service.modules.hint.api.dto.request.CreateHintRequest;
import auth_service.modules.hint.api.dto.response.AdminHintListResponse; import auth_service.modules.hint.api.dto.response.AdminHintListResponse;
import auth_service.modules.hint.api.dto.response.AdminHintResponse; import auth_service.modules.hint.api.dto.response.AdminHintResponse;
import auth_service.modules.hint.api.dto.response.AutoHintResponse;
import auth_service.modules.hint.api.dto.response.CreatedHintResponse; import auth_service.modules.hint.api.dto.response.CreatedHintResponse;
import auth_service.modules.hint.model.AdminHint; import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.model.HintTarget;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.service.AdminHintService; import auth_service.modules.hint.service.AdminHintService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
@@ -32,14 +35,18 @@ public class AdminHintController {
@GetMapping @GetMapping
public ResponseEntity<AdminHintListResponse> findAll() { public ResponseEntity<AdminHintListResponse> findAll() {
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream() return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
.map(AdminHintController::toApi).toList())); .map(AdminHintController::toApi).toList(),
adminHintService.findAutoHints().stream()
.map(state -> new AutoHintResponse(state.key().name(), state.enabled())).toList()));
} }
@PostMapping @PostMapping
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody CreateHintRequest request) { @Valid @RequestBody CreateHintRequest request) {
long id = adminHintService.create(principal.getIdAsLong(), long id = adminHintService.create(principal.getIdAsLong(),
new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt())); new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt(),
new HintTarget(Boolean.TRUE.equals(request.targetNoSteam()), request.targetRole(),
request.targetNewDays(), Boolean.TRUE.equals(request.targetEmptyLibrary()))));
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id)); return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
} }
@@ -49,9 +56,19 @@ public class AdminHintController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/auto/{key}")
public ResponseEntity<Void> setAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key,
@Valid @RequestBody AutoHintRequest request) {
adminHintService.setAutoHintEnabled(principal.getIdAsLong(), key, request.enabled());
return ResponseEntity.noContent().build();
}
private static AdminHintResponse toApi(AdminHint admin) { private static AdminHintResponse toApi(AdminHint admin) {
Hint hint = admin.hint(); Hint hint = admin.hint();
HintTarget target = hint.target();
return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(), return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount()); hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount(),
target.noSteam(), target.role() == null ? null : target.role().name(), target.newDays(),
target.emptyLibrary());
} }
} }
@@ -2,6 +2,7 @@ package auth_service.modules.hint.api;
import auth_service.modules.hint.api.dto.response.HintListResponse; import auth_service.modules.hint.api.dto.response.HintListResponse;
import auth_service.modules.hint.api.dto.response.HintResponse; import auth_service.modules.hint.api.dto.response.HintResponse;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.service.HintService; import auth_service.modules.hint.service.HintService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
@@ -23,8 +24,23 @@ public class HintController {
@GetMapping @GetMapping
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream() long userId = principal.getIdAsLong();
.map(HintController::toApi).toList())); return ResponseEntity.ok(new HintListResponse(
hintService.activeHints(userId, principal.role()).stream().map(HintController::toApi).toList(),
hintService.autoHints(userId).stream().map(AutoHint::name).toList()));
}
@PutMapping(value = "/auto/{key}/snooze")
public ResponseEntity<Void> snoozeAuto(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String key) {
hintService.snoozeAutoHint(principal.getIdAsLong(), key);
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/auto/{key}/done")
public ResponseEntity<Void> hideAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key) {
hintService.hideAutoHintForGood(principal.getIdAsLong(), key);
return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/{hintId}/done") @PutMapping(value = "/{hintId}/done")
@@ -35,6 +51,6 @@ public class HintController {
private static HintResponse toApi(Hint hint) { private static HintResponse toApi(Hint hint) {
return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(), return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
hint.endsAt()); hint.endsAt(), hint.target().emptyLibrary());
} }
} }
@@ -0,0 +1,6 @@
package auth_service.modules.hint.api.dto.request;
import jakarta.validation.constraints.NotNull;
public record AutoHintRequest(@NotNull Boolean enabled) {
}
@@ -1,6 +1,9 @@
package auth_service.modules.hint.api.dto.request; package auth_service.modules.hint.api.dto.request;
import auth_service.modules.hint.model.HintKind; import auth_service.modules.hint.model.HintKind;
import auth_service.shared.security.Role;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -8,8 +11,12 @@ import jakarta.validation.constraints.Size;
import java.time.Instant; import java.time.Instant;
/** /**
* Plain text, never read as HTML. * Plain text with {@code **bold**} and {@code [text](url)}; the Frontend renders it, never as HTML.
* Target fields are optional, missing = every user. Wrappers, because a missing primitive is a 400.
* {@code targetRole} means this role and higher.
*/ */
public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe, public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe,
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt) { @NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt,
Boolean targetNoSteam, Role targetRole, @Min(1) @Max(365) Integer targetNewDays,
Boolean targetEmptyLibrary) {
} }
@@ -2,5 +2,5 @@ package auth_service.modules.hint.api.dto.response;
import java.util.List; import java.util.List;
public record AdminHintListResponse(List<AdminHintResponse> results) { public record AdminHintListResponse(List<AdminHintResponse> results, List<AutoHintResponse> automatic) {
} }
@@ -3,5 +3,7 @@ package auth_service.modules.hint.api.dto.response;
import java.time.Instant; import java.time.Instant;
public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt, public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
String status, long createdBy, Instant createdAt, long doneCount) { String status, long createdBy, Instant createdAt, long doneCount,
boolean targetNoSteam, String targetRole, Integer targetNewDays,
boolean targetEmptyLibrary) {
} }
@@ -0,0 +1,4 @@
package auth_service.modules.hint.api.dto.response;
public record AutoHintResponse(String key, boolean enabled) {
}
@@ -2,5 +2,8 @@ package auth_service.modules.hint.api.dto.response;
import java.util.List; import java.util.List;
public record HintListResponse(List<HintResponse> results) { /**
* @param automatic keys of the automatic hints that are on and not hidden for this user
*/
public record HintListResponse(List<HintResponse> results, List<String> automatic) {
} }
@@ -2,5 +2,6 @@ package auth_service.modules.hint.api.dto.response;
import java.time.Instant; import java.time.Instant;
public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt) { public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
boolean emptyLibraryOnly) {
} }
@@ -4,4 +4,8 @@ public class HintNotFoundException extends RuntimeException {
public HintNotFoundException(long hintId) { public HintNotFoundException(long hintId) {
super("No hint with id " + hintId); super("No hint with id " + hintId);
} }
public HintNotFoundException(String key) {
super("No automatic hint " + key);
}
} }
@@ -0,0 +1,18 @@
package auth_service.modules.hint.model;
import auth_service.modules.hint.exception.HintNotFoundException;
/**
* Built-in hints. The Frontend owns the texts and the conditions.
*/
public enum AutoHint {
NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE;
public static AutoHint parse(String key) {
try {
return valueOf(key);
} catch (IllegalArgumentException _) {
throw new HintNotFoundException(key);
}
}
}
@@ -0,0 +1,4 @@
package auth_service.modules.hint.model;
public record AutoHintState(AutoHint key, boolean enabled) {
}
@@ -5,5 +5,6 @@ import java.time.Instant;
/** /**
* @param textDe null: German users see {@code textEn} * @param textDe null: German users see {@code textEn}
*/ */
public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) { public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
HintTarget target) {
} }
@@ -0,0 +1,15 @@
package auth_service.modules.hint.model;
import auth_service.shared.security.Role;
/**
* Target groups of a hint, combined with AND.
*
* @param role null: every role; else this role and higher (by the active role)
* @param newDays null: any registration date; else registered less than this many days ago
* @param emptyLibrary checked by the Frontend with Core's library summary
*/
public record HintTarget(boolean noSteam, Role role, Integer newDays, boolean emptyLibrary) {
public static final HintTarget ALL = new HintTarget(false, null, null, false);
}
@@ -2,5 +2,6 @@ package auth_service.modules.hint.model;
import java.time.Instant; import java.time.Instant;
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) { public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
HintTarget target) {
} }
@@ -1,23 +1,31 @@
package auth_service.modules.hint.repo; package auth_service.modules.hint.repo;
import auth_service.modules.hint.model.AdminHint; import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.AutoHintState;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.model.HintKind; import auth_service.modules.hint.model.HintKind;
import auth_service.modules.hint.model.HintStatus; import auth_service.modules.hint.model.HintStatus;
import auth_service.modules.hint.model.HintTarget;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.shared.security.Role;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@Repository @Repository
public class HintRepository { public class HintRepository {
private static final String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at"; private static final String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at, "
+ "h.target_no_steam, h.target_role, h.target_new_days, h.target_empty_library";
private static final RowMapper<Hint> HINT = (rs, row) -> new Hint( private static final RowMapper<Hint> HINT = (rs, row) -> new Hint(
rs.getLong("id"), rs.getLong("id"),
@@ -25,7 +33,9 @@ public class HintRepository {
rs.getString("text_de"), rs.getString("text_de"),
HintKind.valueOf(rs.getString("kind")), HintKind.valueOf(rs.getString("kind")),
rs.getTimestamp("starts_at").toInstant(), rs.getTimestamp("starts_at").toInstant(),
rs.getTimestamp("ends_at").toInstant()); rs.getTimestamp("ends_at").toInstant(),
new HintTarget(rs.getBoolean("target_no_steam"), role(rs.getString("target_role")),
rs.getObject("target_new_days", Integer.class), rs.getBoolean("target_empty_library")));
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@@ -33,13 +43,56 @@ public class HintRepository {
this.jdbc = jdbc; this.jdbc = jdbc;
} }
public List<Hint> findActiveFor(long userId, Instant now) { /**
* @param role the active role of the caller; a target role matches this role and higher
*/
public List<Hint> findActiveFor(long userId, Role role, Instant now) {
Timestamp at = Timestamp.from(now); Timestamp at = Timestamp.from(now);
// registration_date is naive UTC
LocalDateTime utc = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
return jdbc.query(""" return jdbc.query("""
SELECT %s FROM hint h SELECT %s FROM hint h JOIN users u ON u.id = ?
WHERE h.starts_at <= ? AND h.ends_at > ? WHERE h.starts_at <= ? AND h.ends_at > ?
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = ?) AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = u.id)
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, at, at, userId); AND (NOT h.target_no_steam OR u.steam_id IS NULL)
AND (h.target_role IS NULL
OR CASE h.target_role WHEN 'USER' THEN 0 WHEN 'AUDITOR' THEN 1 ELSE 2 END <= ?)
AND (h.target_new_days IS NULL
OR u.registration_date > ?::timestamp - make_interval(days => h.target_new_days))
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, userId, at, at, role.ordinal(),
utc);
}
public List<AutoHint> findVisibleAutoHints(long userId, Instant now) {
return jdbc.query("""
SELECT a.hint_key FROM auto_hint a
WHERE a.enabled
AND NOT EXISTS (SELECT 1 FROM auto_hint_hidden x
WHERE x.user_id = ? AND x.hint_key = a.hint_key
AND (x.hidden_until IS NULL OR x.hidden_until > ?))""",
(rs, row) -> AutoHint.valueOf(rs.getString("hint_key")), userId, Timestamp.from(now))
.stream().sorted().toList();
}
/**
* @param until null: for good. A snooze never undoes "for good".
*/
public void hideAutoHint(long userId, AutoHint key, Instant until) {
jdbc.update("""
INSERT INTO auto_hint_hidden (user_id, hint_key, hidden_until) VALUES (?, ?, ?)
ON CONFLICT (user_id, hint_key) DO UPDATE SET hidden_until =
CASE WHEN auto_hint_hidden.hidden_until IS NULL THEN NULL ELSE EXCLUDED.hidden_until END""",
userId, key.name(), until == null ? null : Timestamp.from(until));
}
public List<AutoHintState> findAutoHints() {
return jdbc.query("SELECT hint_key, enabled FROM auto_hint",
(rs, row) -> new AutoHintState(AutoHint.valueOf(rs.getString("hint_key")), rs.getBoolean("enabled")))
.stream().sorted(Comparator.comparing(AutoHintState::key)).toList();
}
public void setAutoHintEnabled(AutoHint key, boolean enabled) {
jdbc.update("UPDATE auto_hint SET enabled = ? WHERE hint_key = ?", enabled, key.name());
} }
public List<AdminHint> findAll(Instant now) { public List<AdminHint> findAll(Instant now) {
@@ -61,10 +114,14 @@ public class HintRepository {
} }
public long insert(NewHint hint, long createdBy) { public long insert(NewHint hint, long createdBy) {
HintTarget target = hint.target();
return Objects.requireNonNull(jdbc.queryForObject(""" return Objects.requireNonNull(jdbc.queryForObject("""
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by) VALUES (?, ?, ?, ?, ?, ?) INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by, target_no_steam, target_role,
target_new_days, target_empty_library)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(), RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(),
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy)); Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy, target.noSteam(),
target.role() == null ? null : target.role().name(), target.newDays(), target.emptyLibrary()));
} }
public void markDone(long hintId, long userId) { public void markDone(long hintId, long userId) {
@@ -81,4 +138,9 @@ public class HintRepository {
return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?", return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?",
at, at, hintId, at) > 0; at, at, hintId, at) > 0;
} }
// Not Role.parse: it turns null into USER, and null here means "every role".
private static Role role(String name) {
return name == null ? null : Role.valueOf(name);
}
} }
@@ -3,6 +3,8 @@ package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException; import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.exception.InvalidHintException; import auth_service.modules.hint.exception.InvalidHintException;
import auth_service.modules.hint.model.AdminHint; import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.AutoHintState;
import auth_service.modules.hint.model.NewHint; import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.repo.HintRepository; import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.audit.AdminAuditLog; import auth_service.shared.audit.AdminAuditLog;
@@ -38,8 +40,8 @@ public class AdminHintService {
throw new InvalidHintException("The end must be in the future"); throw new InvalidHintException("The end must be in the future");
} }
String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe(); String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe();
long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt()), long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt(),
actorUserId); hint.target()), actorUserId);
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id)); adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
log.info("Admin {} created hint {}", actorUserId, id); log.info("Admin {} created hint {}", actorUserId, id);
return id; return id;
@@ -55,4 +57,16 @@ public class AdminHintService {
log.info("Admin {} ended hint {}", actorUserId, hintId); log.info("Admin {} ended hint {}", actorUserId, hintId);
} }
} }
public List<AutoHintState> findAutoHints() {
return hintRepository.findAutoHints();
}
@Transactional
public void setAutoHintEnabled(long actorUserId, String key, boolean enabled) {
AutoHint hint = AutoHint.parse(key);
hintRepository.setAutoHintEnabled(hint, enabled);
adminAuditLog.record(actorUserId, enabled ? "AUTO_HINT_ON" : "AUTO_HINT_OFF", null, hint.name());
log.info("Admin {} turned automatic hint {} {}", actorUserId, hint, enabled ? "on" : "off");
}
} }
@@ -1,6 +1,7 @@
package auth_service.modules.hint.service; package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException; import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.Hint; import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.repo.HintRepository; import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.security.RequiresRole; import auth_service.shared.security.RequiresRole;
@@ -8,6 +9,7 @@ import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -16,10 +18,24 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class HintService { public class HintService {
private static final Duration SNOOZE = Duration.ofDays(7);
private final HintRepository hintRepository; private final HintRepository hintRepository;
public List<Hint> activeHints(long userId) { public List<Hint> activeHints(long userId, Role role) {
return hintRepository.findActiveFor(userId, Instant.now()); return hintRepository.findActiveFor(userId, role, Instant.now());
}
public List<AutoHint> autoHints(long userId) {
return hintRepository.findVisibleAutoHints(userId, Instant.now());
}
public void snoozeAutoHint(long userId, String key) {
hintRepository.hideAutoHint(userId, AutoHint.parse(key), Instant.now().plus(SNOOZE));
}
public void hideAutoHintForGood(long userId, String key) {
hintRepository.hideAutoHint(userId, AutoHint.parse(key), null);
} }
public void markDone(long userId, long hintId) { public void markDone(long userId, long hintId) {
@@ -5,6 +5,8 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.ToString; import lombok.ToString;
import java.time.Instant;
@Builder @Builder
@Data @Data
public class User { public class User {
@@ -18,6 +20,7 @@ public class User {
private boolean pending; private boolean pending;
@Builder.Default @Builder.Default
private Role role = Role.USER; private Role role = Role.USER;
private Instant roleChangedAt;
public boolean canLogin() { public boolean canLogin() {
return enabled && !deleted && !pending; return enabled && !deleted && !pending;
@@ -18,5 +18,11 @@ public interface MinimalUserRepository {
Optional<User> findById(long userId); Optional<User> findById(long userId);
/**
* Reads the password hash and holds a lock on the row until the transaction ends, so a password change commits
* before or after the caller's writes, never between.
*/
Optional<String> lockPasswordHash(long userId);
void updateLoginDate(long userId); void updateLoginDate(long userId);
} }
@@ -18,7 +18,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
private static final String SELECT_USER = private static final String SELECT_USER =
"SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status = 'PENDING' AS pending, " "SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status = 'PENDING' AS pending, "
+ "role FROM users "; + "role, role_changed_at FROM users ";
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder() private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
.id(rs.getLong("id")) .id(rs.getLong("id"))
@@ -29,6 +29,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
.deleted(rs.getBoolean("deleted")) .deleted(rs.getBoolean("deleted"))
.pending(rs.getBoolean("pending")) .pending(rs.getBoolean("pending"))
.role(Role.parse(rs.getString("role"))) .role(Role.parse(rs.getString("role")))
.roleChangedAt(rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant())
.build(); .build();
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@@ -43,6 +44,12 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
return jdbc.query(SELECT_USER + "WHERE id = ?", USER_ROW, userId).stream().findFirst(); return jdbc.query(SELECT_USER + "WHERE id = ?", USER_ROW, userId).stream().findFirst();
} }
@Override
public Optional<String> lockPasswordHash(long userId) {
return jdbc.query("SELECT password FROM users WHERE id = ? FOR SHARE", (rs, row) -> rs.getString("password"),
userId).stream().findFirst();
}
@Override @Override
public void updateLoginDate(long userId) { public void updateLoginDate(long userId) {
jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId); jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId);
@@ -14,6 +14,11 @@ public interface RefreshTokenRepository {
*/ */
void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole); void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole);
/**
* Without a lock: the caller locks the user first, as a password change does, so the two cannot deadlock.
*/
Optional<Long> findUserIdByHash(String tokenHash);
/** /**
* Locks the row until the transaction ends, so two refreshes with the same token run one after the other. * Locks the row until the transaction ends, so two refreshes with the same token run one after the other.
*/ */
@@ -31,6 +31,15 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
entityManager.persist(entity); entityManager.persist(entity);
} }
@Override
public Optional<Long> findUserIdByHash(String tokenHash) {
return entityManager.createQuery("SELECT t.userId FROM RefreshTokenEntity t WHERE t.tokenHash = :hash", Long.class)
.setParameter("hash", tokenHash)
.getResultList()
.stream()
.findFirst();
}
@Override @Override
public Optional<RefreshToken> findByHashForUpdate(String tokenHash) { public Optional<RefreshToken> findByHashForUpdate(String tokenHash) {
return entityManager.createQuery( return entityManager.createQuery(
@@ -4,6 +4,7 @@ import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository; import auth_service.modules.login.persistence.MinimalUserRepository;
import auth_service.shared.security.ActiveUserCheck; import auth_service.shared.security.ActiveUserCheck;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import auth_service.shared.security.UserTokenState;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,7 +21,9 @@ public class ActiveUserService implements ActiveUserCheck {
@Override @Override
@Cacheable(value = "activeUsers", key = "#userId") @Cacheable(value = "activeUsers", key = "#userId")
public boolean isActive(long userId) { public UserTokenState state(long userId) {
return minimalUserRepository.findById(userId).filter(User::canLogin).isPresent(); return minimalUserRepository.findById(userId).filter(User::canLogin)
.map(user -> new UserTokenState(true, user.getRoleChangedAt()))
.orElse(UserTokenState.INACTIVE);
} }
} }
@@ -1,7 +1,6 @@
package auth_service.modules.login.service; package auth_service.modules.login.service;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.AccountBlockedException; import auth_service.modules.login.exception.AccountBlockedException;
import auth_service.modules.login.exception.AccountPendingException; import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.InvalidCredentialsException;
@@ -23,7 +22,6 @@ import java.util.UUID;
public class LoginService { public class LoginService {
private final MinimalUserRepository minimalUserRepository; private final MinimalUserRepository minimalUserRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final TokenGenerator tokenGenerator;
private final LoginAttemptService loginAttemptService; private final LoginAttemptService loginAttemptService;
private final RefreshTokenService refreshTokenService; private final RefreshTokenService refreshTokenService;
private final CacheManager cacheManager; private final CacheManager cacheManager;
@@ -32,11 +30,10 @@ public class LoginService {
private final String dummyHash; private final String dummyHash;
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder, public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService, LoginAttemptService loginAttemptService, RefreshTokenService refreshTokenService,
RefreshTokenService refreshTokenService, CacheManager cacheManager) { CacheManager cacheManager) {
this.minimalUserRepository = minimalUserRepository; this.minimalUserRepository = minimalUserRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.tokenGenerator = tokenGenerator;
this.loginAttemptService = loginAttemptService; this.loginAttemptService = loginAttemptService;
this.refreshTokenService = refreshTokenService; this.refreshTokenService = refreshTokenService;
this.cacheManager = cacheManager; this.cacheManager = cacheManager;
@@ -45,7 +42,8 @@ public class LoginService {
/** /**
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the * Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right password. * same InvalidCredentialsException; a pending or blocked account says so, but only after the right
* password. An unconfirmed user has no password and fails like a wrong one.
* *
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it, * @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
* so its login is revoked; else it would stay valid unseen for 30 days. * so its login is revoked; else it would stay valid unseen for 30 days.
@@ -81,7 +79,6 @@ public class LoginService {
users.evict(user.getId()); users.evict(user.getId());
} }
refreshTokenService.revoke(previousRefreshToken); refreshTokenService.revoke(previousRefreshToken);
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getSteamId(), user.getRole()); return refreshTokenService.startSession(user.getId(), hash);
return new Session(accessToken, refreshTokenService.startFamily(user.getId()));
} }
} }
@@ -1,5 +1,6 @@
package auth_service.modules.login.service; package auth_service.modules.login.service;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.model.RefreshToken; import auth_service.modules.login.model.RefreshToken;
import auth_service.modules.login.model.Session; import auth_service.modules.login.model.Session;
import auth_service.modules.login.model.User; import auth_service.modules.login.model.User;
@@ -46,9 +47,22 @@ public class RefreshTokenService {
private final SessionProperties sessionProperties; private final SessionProperties sessionProperties;
private final AdminAuditLog adminAuditLog; private final AdminAuditLog adminAuditLog;
/**
* @param passwordHash the hash the login checked. A password change in between ended every login, so this one
* must not start.
* @throws InvalidCredentialsException when the password is not the one checked
*/
@Transactional @Transactional
public String startFamily(long userId) { public Session startSession(long userId, String passwordHash) {
return issue(userId, UUID.randomUUID(), Instant.now(), null); if (!userRepository.lockPasswordHash(userId).map(current -> current.equals(passwordHash)).orElse(false)) {
throw new InvalidCredentialsException("Invalid email or password");
}
// Read after the lock, so a role change or block that committed during the login is seen.
User current = userRepository.findById(userId).filter(User::canLogin)
.orElseThrow(() -> new InvalidCredentialsException("Invalid email or password"));
String accessToken = tokenGenerator.generateToken(String.valueOf(userId), current.getSteamId(),
current.getRole(), current.getRoleChangedAt());
return new Session(accessToken, issue(userId, UUID.randomUUID(), Instant.now(), null));
} }
/** /**
@@ -79,6 +93,12 @@ public class RefreshTokenService {
return Optional.empty(); return Optional.empty();
} }
Instant now = Instant.now(); Instant now = Instant.now();
// The user lock comes before the token lock, as in a password change. A change that commits after this
// point sees the new token when it revokes; one that committed before has revoked this token already.
Optional<Long> owner = refreshTokenRepository.findUserIdByHash(hash(rawToken));
if (owner.isEmpty() || userRepository.lockPasswordHash(owner.get()).isEmpty()) {
return Optional.empty();
}
Optional<RefreshToken> found = refreshTokenRepository.findByHashForUpdate(hash(rawToken)); Optional<RefreshToken> found = refreshTokenRepository.findByHashForUpdate(hash(rawToken));
if (found.isEmpty()) { if (found.isEmpty()) {
return Optional.empty(); return Optional.empty();
@@ -110,7 +130,8 @@ public class RefreshTokenService {
if (switching && stored != Role.USER) { if (switching && stored != Role.USER) {
adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name()); adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name());
} }
String accessToken = tokenGenerator.generateToken(String.valueOf(current.getId()), current.getSteamId(), active); String accessToken = tokenGenerator.generateToken(String.valueOf(current.getId()), current.getSteamId(), active,
current.getRoleChangedAt());
return Optional.of(new Session(accessToken, next)); return Optional.of(new Session(accessToken, next));
} }
@@ -0,0 +1,69 @@
package auth_service.modules.mail.api;
import auth_service.modules.mail.api.dto.request.BroadcastRequest;
import auth_service.modules.mail.api.dto.response.BroadcastListResponse;
import auth_service.modules.mail.api.dto.response.BroadcastResponse;
import auth_service.modules.mail.api.dto.response.RecipientCountResponse;
import auth_service.modules.mail.api.dto.response.SentBroadcastResponse;
import auth_service.modules.mail.model.Broadcast;
import auth_service.modules.mail.model.BroadcastText;
import auth_service.modules.mail.service.BroadcastService;
import auth_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/broadcasts")
@RequiredArgsConstructor
public class AdminBroadcastController {
private final BroadcastService broadcastService;
@GetMapping
public ResponseEntity<BroadcastListResponse> findAll() {
return ResponseEntity.ok(new BroadcastListResponse(broadcastService.findAll().stream()
.map(AdminBroadcastController::toApi).toList()));
}
@GetMapping("/recipient-count")
public ResponseEntity<RecipientCountResponse> recipientCount(@RequestParam(required = false) List<Long> userIds) {
return ResponseEntity.ok(new RecipientCountResponse(broadcastService.countRecipients(userIds)));
}
@PostMapping
public ResponseEntity<SentBroadcastResponse> send(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody BroadcastRequest request) {
int queued = broadcastService.send(principal.getIdAsLong(), toText(request), request.userIds());
return ResponseEntity.status(HttpStatus.CREATED).body(new SentBroadcastResponse(queued));
}
@PostMapping("/test")
public ResponseEntity<Void> sendTest(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody BroadcastRequest request) {
broadcastService.sendTest(principal.getIdAsLong(), toText(request));
return ResponseEntity.noContent().build();
}
private static BroadcastText toText(BroadcastRequest request) {
return new BroadcastText(request.subjectEn().strip(), request.subjectDe().strip(),
request.bodyEn().strip(), request.bodyDe().strip());
}
private static BroadcastResponse toApi(Broadcast broadcast) {
BroadcastText text = broadcast.text();
return new BroadcastResponse(broadcast.id(), text.subjectEn(), text.subjectDe(), text.bodyEn(), text.bodyDe(),
broadcast.createdBy(), broadcast.createdAt(), broadcast.recipientCount(), broadcast.sentCount(),
broadcast.failedCount());
}
}
@@ -0,0 +1,21 @@
package auth_service.modules.mail.api;
import auth_service.modules.mail.exception.NoRecipientsException;
import auth_service.shared.api.dto.response.ErrorResponse;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = AdminBroadcastController.class)
@Order(value = 1)
public class BroadcastExceptionController {
@ExceptionHandler(NoRecipientsException.class)
public ResponseEntity<ErrorResponse> conflict(NoRecipientsException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("NO_RECIPIENTS", ex.getMessage()));
}
}
@@ -0,0 +1,16 @@
package auth_service.modules.mail.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* Plain text, never HTML.
*
* @param userIds null = every active user; the test mail ignores it
*/
public record BroadcastRequest(@NotBlank @Size(max = 150) String subjectEn, @NotBlank @Size(max = 150) String subjectDe,
@NotBlank @Size(max = 5000) String bodyEn, @NotBlank @Size(max = 5000) String bodyDe,
List<Long> userIds) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.mail.api.dto.response;
import java.util.List;
public record BroadcastListResponse(List<BroadcastResponse> results) {
}
@@ -0,0 +1,8 @@
package auth_service.modules.mail.api.dto.response;
import java.time.Instant;
public record BroadcastResponse(long id, String subjectEn, String subjectDe, String bodyEn, String bodyDe,
long createdBy, Instant createdAt, int recipientCount, int sentCount,
int failedCount) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.mail.api.dto.response;
public record RecipientCountResponse(long count) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.mail.api.dto.response;
public record SentBroadcastResponse(int recipientCount) {
}
@@ -0,0 +1,8 @@
package auth_service.modules.mail.exception;
public class NoRecipientsException extends RuntimeException {
public NoRecipientsException() {
super("No active user among the recipients");
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.mail.model;
import java.time.Instant;
public record Broadcast(long id, BroadcastText text, long createdBy, Instant createdAt, int recipientCount,
int sentCount, int failedCount) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.mail.model;
public record BroadcastText(String subjectEn, String subjectDe, String bodyEn, String bodyDe) {
}
@@ -29,20 +29,27 @@ public enum MailTemplate {
%s %s
"""), """),
REGISTRATION_RECEIVED( // %1$s greeting, %2$s link.
"Your ToBePlayed registration has arrived", """ CONFIRM_EMAIL(
"Confirm your email address for ToBePlayed", """
Hello %s, Hello %s,
thank you for your registration. An administrator checks each new account. thank you for your registration. Please confirm your email address with this link (valid for 24 hours):
You get a mail as soon as it is decided. %s
After that an administrator checks your request, and you get a mail as soon as it is decided.
If you did not register, ignore this mail.
ToBePlayed ToBePlayed
""", """,
"Deine ToBePlayed-Registrierung ist angekommen", """ "Bestätige deine E-Mail-Adresse für ToBePlayed", """
Hallo %s, Hallo %s,
danke für deine Registrierung. Ein Administrator prüft jedes neue Konto. danke für deine Registrierung. Bitte bestätige deine E-Mail-Adresse über diesen Link (24 Stunden gültig):
Du bekommst eine Mail, sobald entschieden ist. %s
Danach prüft ein Administrator deine Anfrage, und du bekommst eine Mail, sobald entschieden ist.
Wenn du dich nicht registriert hast, ignoriere diese Mail.
ToBePlayed ToBePlayed
"""), """),
@@ -117,6 +124,67 @@ public enum MailTemplate {
Alle deine Daten werden ab dem %s gelöscht. Alle deine Daten werden ab dem %s gelöscht.
ToBePlayed ToBePlayed
"""),
// %1$s greeting, %2$s link.
PASSWORD_RESET(
"Reset your ToBePlayed password", """
Hello %s,
someone asked to reset the password of your account. Choose a new password with this link (valid for 1 hour, once):
%s
If it was not you, ignore this mail; your password stays as it is.
ToBePlayed
""",
"Setze dein ToBePlayed-Passwort zurück", """
Hallo %s,
jemand möchte das Passwort deines Kontos zurücksetzen. Wähle über diesen Link ein neues Passwort (1 Stunde gültig, einmal nutzbar):
%s
Wenn du das nicht warst, ignoriere diese Mail; dein Passwort bleibt, wie es ist.
ToBePlayed
"""),
// %1$s greeting, %2$s link to the reset page.
PASSWORD_CHANGED(
"Your ToBePlayed password was changed", """
Hello %s,
the password of your account was changed, and your other logins were ended.
If it was not you, reset your password at once:
%s
and answer this mail.
ToBePlayed
""",
"Dein ToBePlayed-Passwort wurde geändert", """
Hallo %s,
das Passwort deines Kontos wurde geändert, und deine anderen Anmeldungen wurden beendet.
Wenn du das nicht warst, setze dein Passwort sofort zurück:
%s
und antworte auf diese Mail.
ToBePlayed
"""),
// Admin text only: %1$s subject, %2$s body.
BROADCAST(
"%1$s", """
%2$s
--
You get this mail because you have an account on ToBePlayed.
""",
"%1$s", """
%2$s
--
Du bekommst diese Mail, weil du ein Konto bei ToBePlayed hast.
"""); """);
private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy"); private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
@@ -135,14 +203,14 @@ public enum MailTemplate {
*/ */
public Mail render(String language, Object... args) { public Mail render(String language, Object... args) {
Mail mail = isGerman(language) ? de : en; Mail mail = isGerman(language) ? de : en;
return new Mail(mail.subject(), mail.body().formatted(args)); return new Mail(mail.subject().formatted(args), mail.body().formatted(args));
} }
public static String date(String language, LocalDate date) { public static String date(String language, LocalDate date) {
return (isGerman(language) ? DATE_DE : DATE_EN).format(date); return (isGerman(language) ? DATE_DE : DATE_EN).format(date);
} }
private static boolean isGerman(String language) { public static boolean isGerman(String language) {
return "de".equals(language); return "de".equals(language);
} }
} }
@@ -1,4 +1,8 @@
package auth_service.modules.mail.model; package auth_service.modules.mail.model;
public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts) { /**
* @param broadcastId null for an account mail
*/
public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts,
Long broadcastId) {
} }
@@ -0,0 +1,83 @@
package auth_service.modules.mail.repo;
import auth_service.modules.mail.model.Broadcast;
import auth_service.modules.mail.model.BroadcastText;
import auth_service.modules.mail.model.Mail;
import auth_service.shared.account.ActiveUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@Repository
public class BroadcastRepository {
static final String RECIPIENTS = "FROM users u JOIN user_settings s ON s.user_id = u.id WHERE "
+ ActiveUser.of("u");
private final JdbcTemplate jdbc;
private final NamedParameterJdbcTemplate namedJdbc;
public BroadcastRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) {
this.jdbc = jdbc;
this.namedJdbc = namedJdbc;
}
public long countRecipients(List<Long> userIds) {
return Objects.requireNonNull(namedJdbc.queryForObject("SELECT COUNT(*) " + recipients(userIds),
ids(userIds), Long.class));
}
public long insert(BroadcastText text, long createdBy, Instant createdAt) {
return Objects.requireNonNull(jdbc.queryForObject("""
INSERT INTO mail_broadcast (subject_en, subject_de, body_en, body_de, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id""", Long.class, text.subjectEn(), text.subjectDe(),
text.bodyEn(), text.bodyDe(), createdBy, Timestamp.from(createdAt)));
}
/**
* One outbox row per recipient, in the language of the user (English when none is set).
*
* @return the number of rows queued
*/
public int queue(long broadcastId, List<Long> userIds, Mail en, Mail de) {
return namedJdbc.update("""
INSERT INTO mail_outbox (kind, user_id, to_address, subject, body, broadcast_id)
SELECT 'BROADCAST', u.id, u.email, CASE WHEN s.language = 'de' THEN :subjectDe ELSE :subjectEn END,
CASE WHEN s.language = 'de' THEN :bodyDe ELSE :bodyEn END, :broadcastId
""" + recipients(userIds), ids(userIds)
.addValue("subjectEn", en.subject()).addValue("bodyEn", en.body())
.addValue("subjectDe", de.subject()).addValue("bodyDe", de.body())
.addValue("broadcastId", broadcastId));
}
public void setRecipientCount(long broadcastId, int count) {
jdbc.update("UPDATE mail_broadcast SET recipient_count = ? WHERE id = ?", count, broadcastId);
}
public List<Broadcast> findAll() {
return jdbc.query("SELECT * FROM mail_broadcast ORDER BY created_at DESC, id DESC",
(rs, row) -> new Broadcast(rs.getLong("id"),
new BroadcastText(rs.getString("subject_en"), rs.getString("subject_de"),
rs.getString("body_en"), rs.getString("body_de")),
rs.getLong("created_by"), rs.getTimestamp("created_at").toInstant(),
rs.getInt("recipient_count"), rs.getInt("sent_count"), rs.getInt("failed_count")));
}
private static String recipients(List<Long> userIds) {
if (userIds == null) {
return RECIPIENTS;
}
// An empty list would make "IN ()", a syntax error.
return RECIPIENTS + (userIds.isEmpty() ? " AND FALSE" : " AND u.id IN (:ids)");
}
private static MapSqlParameterSource ids(List<Long> userIds) {
return new MapSqlParameterSource("ids", userIds);
}
}
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@@ -39,16 +40,19 @@ public class MailOutboxRepository {
public List<OutboxRow> findDue(Instant now, int limit) { public List<OutboxRow> findDue(Instant now, int limit) {
return jdbc.query(""" return jdbc.query("""
SELECT id, kind, to_address, subject, body, attempts FROM mail_outbox SELECT id, kind, to_address, subject, body, attempts, broadcast_id FROM mail_outbox
WHERE status = 'PENDING' AND next_attempt_at <= ? WHERE status = 'PENDING' AND next_attempt_at <= ?
ORDER BY id LIMIT ?""", ORDER BY broadcast_id NULLS FIRST, id LIMIT ?""",
(rs, row) -> new OutboxRow(rs.getLong("id"), rs.getString("kind"), rs.getString("to_address"), (rs, row) -> new OutboxRow(rs.getLong("id"), rs.getString("kind"), rs.getString("to_address"),
rs.getString("subject"), rs.getString("body"), rs.getInt("attempts")), rs.getString("subject"), rs.getString("body"), rs.getInt("attempts"), rs.getObject("broadcast_id", Long.class)),
Timestamp.from(now), limit); Timestamp.from(now), limit);
} }
public void markSent(long id, Instant now) { public void markSent(long id, Instant now) {
jdbc.update("UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL WHERE id = ?", jdbc.update("""
WITH row AS (UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL
WHERE id = ? RETURNING broadcast_id)
UPDATE mail_broadcast b SET sent_count = sent_count + 1 FROM row WHERE b.id = row.broadcast_id""",
Timestamp.from(now), id); Timestamp.from(now), id);
} }
@@ -58,10 +62,37 @@ public class MailOutboxRepository {
} }
public void markFailed(long id, int attempts, String error) { public void markFailed(long id, int attempts, String error) {
jdbc.update("UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ? WHERE id = ?", jdbc.update("""
WITH row AS (UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ?
WHERE id = ? RETURNING broadcast_id)
UPDATE mail_broadcast b SET failed_count = failed_count + 1 FROM row WHERE b.id = row.broadcast_id""",
attempts, shorten(error), id); attempts, shorten(error), id);
} }
/**
* Fails the pending broadcast rows whose user is no longer active since the queue time.
*
* @return the number of rows failed
*/
public int failBroadcastRowsOfInactiveUsers() {
return Objects.requireNonNull(jdbc.queryForObject("""
WITH skipped AS (
UPDATE mail_outbox o SET status = 'FAILED', last_error = 'recipient inactive'
WHERE o.status = 'PENDING' AND o.broadcast_id IS NOT NULL
AND NOT EXISTS (SELECT 1 %s AND u.id = o.user_id)
RETURNING o.broadcast_id),
counted AS (
UPDATE mail_broadcast b SET failed_count = failed_count + c.n
FROM (SELECT broadcast_id, COUNT(*) AS n FROM skipped GROUP BY broadcast_id) c
WHERE b.id = c.broadcast_id)
SELECT COUNT(*) FROM skipped""".formatted(BroadcastRepository.RECIPIENTS), Integer.class));
}
public int countSentSince(Instant since) {
return Objects.requireNonNull(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE sent_at >= ?",
Integer.class, Timestamp.from(since)));
}
public void deleteSentBefore(Instant cutoff) { public void deleteSentBefore(Instant cutoff) {
jdbc.update("DELETE FROM mail_outbox WHERE status = 'SENT' AND sent_at < ?", Timestamp.from(cutoff)); jdbc.update("DELETE FROM mail_outbox WHERE status = 'SENT' AND sent_at < ?", Timestamp.from(cutoff));
} }
@@ -0,0 +1,78 @@
package auth_service.modules.mail.service;
import auth_service.modules.mail.exception.NoRecipientsException;
import auth_service.modules.mail.model.Broadcast;
import auth_service.modules.mail.model.BroadcastText;
import auth_service.modules.mail.model.Mail;
import auth_service.modules.mail.model.MailTemplate;
import auth_service.modules.mail.model.Recipient;
import auth_service.modules.mail.repo.BroadcastRepository;
import auth_service.modules.mail.repo.MailOutboxRepository;
import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@Service
@RequiresRole(Role.PLATFORM_ADMIN)
@Slf4j
public class BroadcastService {
private final BroadcastRepository broadcasts;
private final MailOutboxRepository outbox;
private final AdminAuditLog adminAuditLog;
public BroadcastService(BroadcastRepository broadcasts, MailOutboxRepository outbox, AdminAuditLog adminAuditLog) {
this.broadcasts = broadcasts;
this.outbox = outbox;
this.adminAuditLog = adminAuditLog;
}
public List<Broadcast> findAll() {
return broadcasts.findAll();
}
/**
* @param userIds null = every active user
*/
public long countRecipients(List<Long> userIds) {
return broadcasts.countRecipients(userIds);
}
/**
* @param userIds null = every active user
* @return the number of mails queued
* @throws NoRecipientsException when nobody is left; nothing is stored then
*/
@Transactional
public int send(long actorUserId, BroadcastText text, List<Long> userIds) {
Mail en = MailTemplate.BROADCAST.render("en", text.subjectEn(), text.bodyEn());
Mail de = MailTemplate.BROADCAST.render("de", text.subjectDe(), text.bodyDe());
long id = broadcasts.insert(text, actorUserId, Instant.now());
int queued = broadcasts.queue(id, userIds, en, de);
if (queued == 0) {
throw new NoRecipientsException();
}
broadcasts.setRecipientCount(id, queued);
adminAuditLog.record(actorUserId, "BROADCAST_SEND", null, String.valueOf(id));
log.info("Admin {} queued broadcast {} for {} users", actorUserId, id, queued);
return queued;
}
/**
* Mails the draft to the admin only, in the admin's language.
*/
@Transactional
public void sendTest(long actorUserId, BroadcastText text) {
Recipient admin = outbox.findRecipient(actorUserId).orElseThrow();
boolean german = MailTemplate.isGerman(admin.language());
Mail mail = MailTemplate.BROADCAST.render(admin.language(), german ? text.subjectDe() : text.subjectEn(),
german ? text.bodyDe() : text.bodyEn());
outbox.insert(MailTemplate.BROADCAST, actorUserId, admin.email(), mail);
}
}
@@ -26,16 +26,21 @@ public class MailOutboxService {
static final int MAX_ATTEMPTS = 10; static final int MAX_ATTEMPTS = 10;
static final Duration KEEP_SENT = Duration.ofDays(30); static final Duration KEEP_SENT = Duration.ofDays(30);
private static final int BATCH = 20; private static final int BATCH = 20;
// Kept free of broadcast rows, so a registration mail still goes out on a broadcast day.
static final int ACCOUNT_RESERVE = 10;
private final MailOutboxRepository repository; private final MailOutboxRepository repository;
private final JavaMailSender sender; private final JavaMailSender sender;
private final String from; private final String from;
private final int dailyLimit;
// An empty SPRING_MAIL_HOST still creates Boot's sender bean, so the host decides, not the bean. // An empty SPRING_MAIL_HOST still creates Boot's sender bean, so the host decides, not the bean.
public MailOutboxService(MailOutboxRepository repository, ObjectProvider<JavaMailSender> sender, public MailOutboxService(MailOutboxRepository repository, ObjectProvider<JavaMailSender> sender,
@Value("${spring.mail.host:}") String host, @Value("${app.mail.from}") String from, @Value("${spring.mail.host:}") String host, @Value("${app.mail.from}") String from,
@Value("${app.mail.admin-to}") String adminTo) { @Value("${app.mail.admin-to}") String adminTo,
@Value("${app.mail.daily-limit}") int dailyLimit) {
this.repository = repository; this.repository = repository;
this.dailyLimit = dailyLimit;
this.from = from; this.from = from;
if (host.isBlank()) { if (host.isBlank()) {
this.sender = null; this.sender = null;
@@ -45,6 +50,10 @@ public class MailOutboxService {
if (from.isBlank() || adminTo.isBlank()) { if (from.isBlank() || adminTo.isBlank()) {
throw new IllegalStateException("SPRING_MAIL_HOST is set, so APP_MAIL_FROM and APP_MAIL_ADMIN_TO are needed"); throw new IllegalStateException("SPRING_MAIL_HOST is set, so APP_MAIL_FROM and APP_MAIL_ADMIN_TO are needed");
} }
if (dailyLimit <= ACCOUNT_RESERVE) {
throw new IllegalStateException("APP_MAIL_DAILY_LIMIT must be above " + ACCOUNT_RESERVE
+ ", the room kept for account mails");
}
this.sender = sender.getObject(); this.sender = sender.getObject();
} }
@@ -57,8 +66,18 @@ public class MailOutboxService {
return; return;
} }
Instant now = Instant.now(); Instant now = Instant.now();
for (OutboxRow row : repository.findDue(now, BATCH)) { int skipped = repository.failBroadcastRowsOfInactiveUsers();
if (skipped > 0) {
log.info("{} broadcast mails skipped: recipient inactive", skipped);
}
int budget = dailyLimit - repository.countSentSince(now.minus(Duration.ofHours(24)));
for (OutboxRow row : repository.findDue(now, Math.min(BATCH, Math.max(budget, 0)))) {
// Account mails come first, so from the first broadcast row on only broadcast rows follow.
if (row.broadcastId() != null && budget <= ACCOUNT_RESERVE) {
break;
}
send(row, now); send(row, now);
budget--; // a failed try counts too: safer than Hostinger's own count
} }
repository.deleteSentBefore(now.minus(KEEP_SENT)); repository.deleteSentBefore(now.minus(KEEP_SENT));
} }
@@ -30,6 +30,12 @@ public class MailService implements AccountMailer {
this.frontendUrl = frontendUrl; this.frontendUrl = frontendUrl;
} }
@Override
public void confirmEmail(long userId, String rawToken) {
repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.CONFIRM_EMAIL, userId, user,
user.greeting(), frontendUrl + "/confirm-email#" + rawToken));
}
@Override @Override
public void registrationRequested(long userId) { public void registrationRequested(long userId) {
repository.findRecipient(userId).ifPresent(user -> { repository.findRecipient(userId).ifPresent(user -> {
@@ -39,7 +45,6 @@ public class MailService implements AccountMailer {
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(), .render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(),
frontendUrl + "/admin/users")); frontendUrl + "/admin/users"));
} }
insertForUser(MailTemplate.REGISTRATION_RECEIVED, userId, user, user.greeting());
}); });
} }
@@ -67,6 +72,18 @@ public class MailService implements AccountMailer {
user.greeting(), MailTemplate.date(user.language(), deletedFrom))); user.greeting(), MailTemplate.date(user.language(), deletedFrom)));
} }
@Override
public void passwordReset(long userId, String rawToken) {
repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.PASSWORD_RESET, userId, user,
user.greeting(), frontendUrl + "/reset-password#" + rawToken));
}
@Override
public void passwordChanged(long userId) {
repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.PASSWORD_CHANGED, userId, user,
user.greeting(), frontendUrl + "/reset-password"));
}
private void insertForUser(MailTemplate template, long userId, Recipient user, Object... args) { private void insertForUser(MailTemplate template, long userId, Recipient user, Object... args) {
repository.insert(template, userId, user.email(), template.render(user.language(), args)); repository.insert(template, userId, user.email(), template.render(user.language(), args));
} }
@@ -35,6 +35,7 @@ public class PublicProfileController {
private static PublicProfileResponse.Style style(PublicProfile profile) { private static PublicProfileResponse.Style style(PublicProfile profile) {
PublicProfile.Style style = profile.style(); PublicProfile.Style style = profile.style();
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(), return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(),
profile.tiles().stream().map(tile -> new PublicProfileResponse.Tile(tile.id(), tile.size())).toList()); profile.tiles().stream().map(tile -> new PublicProfileResponse.Tile(tile.id(), tile.size())).toList(),
style.statCharts());
} }
} }
@@ -2,6 +2,7 @@ package auth_service.modules.profile.api.dto;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* @param ticket for Core's public profile endpoint; valid for a few minutes * @param ticket for Core's public profile endpoint; valid for a few minutes
@@ -11,8 +12,10 @@ public record PublicProfileResponse(String displayName, LocalDate memberSince, S
/** /**
* @param tiles the shown parts of the profile in their order * @param tiles the shown parts of the profile in their order
* @param statCharts the owner's chart form per stat group
*/ */
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<Tile> tiles) {} public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<Tile> tiles,
Map<String, String> statCharts) {}
public record Tile(String id, String size) {} public record Tile(String id, String size) {}
} }
@@ -4,10 +4,11 @@ import auth_service.shared.security.Role;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map;
public record PublicProfile(long userId, Role role, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) { public record PublicProfile(long userId, Role role, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {} public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, Map<String, String> statCharts) {}
/** /**
* @param id a part of the profile, or STATS_ and a stat group of Core * @param id a part of the profile, or STATS_ and a stat group of Core
@@ -7,7 +7,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@@ -26,13 +28,25 @@ public class PublicProfileRepository {
: new PublicProfile.Tile(stored.substring(0, colon), stored.substring(colon + 1)); : new PublicProfile.Tile(stored.substring(0, colon), stored.substring(colon + 1));
} }
// Stored as "GROUP:CHART", like user.model.StatCharts.parse, which this module may not use.
private static Map<String, String> statCharts(String[] stored) {
Map<String, String> charts = new LinkedHashMap<>();
for (String entry : stored) {
int colon = entry.indexOf(':');
if (colon >= 0) {
charts.put(entry.substring(0, colon), entry.substring(colon + 1));
}
}
return charts;
}
/** /**
* Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index. * Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index.
*/ */
public Optional<PublicProfile> findPublicByDisplayName(String displayName) { public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query(""" return jdbc.query("""
SELECT u.id, u.role, u.display_name, u.registration_date, s.profile_accent, s.profile_corners, SELECT u.id, u.role, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles s.profile_banner_appid, s.profile_background_appid, s.profile_tiles, s.stat_charts
FROM users u JOIN user_settings s ON s.user_id = u.id FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
""".formatted(ActiveUser.of("u")), """.formatted(ActiveUser.of("u")),
@@ -41,7 +55,8 @@ public class PublicProfileRepository {
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null, ? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"), new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
rs.getObject("profile_banner_appid", Long.class), rs.getObject("profile_banner_appid", Long.class),
rs.getObject("profile_background_appid", Long.class)), rs.getObject("profile_background_appid", Long.class),
statCharts((String[]) rs.getArray("stat_charts").getArray())),
Arrays.stream((String[]) rs.getArray("profile_tiles").getArray()).map(PublicProfileRepository::tile).toList()), Arrays.stream((String[]) rs.getArray("profile_tiles").getArray()).map(PublicProfileRepository::tile).toList()),
displayName).stream().findFirst(); displayName).stream().findFirst();
} }
@@ -2,16 +2,14 @@ package auth_service.modules.profile.service;
import auth_service.modules.profile.model.PublicProfile; import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.repo.PublicProfileRepository; import auth_service.modules.profile.repo.PublicProfileRepository;
import auth_service.shared.account.ProfileSections;
import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Service @Service
@Public @Public
@@ -20,8 +18,6 @@ public class PublicProfileService {
public record TicketedProfile(PublicProfile profile, String ticket) {} public record TicketedProfile(PublicProfile profile, String ticket) {}
private static final String STATS_PREFIX = "STATS_";
private final PublicProfileRepository publicProfileRepository; private final PublicProfileRepository publicProfileRepository;
private final ProfileTicketIssuer profileTicketIssuer; private final ProfileTicketIssuer profileTicketIssuer;
@@ -33,15 +29,9 @@ public class PublicProfileService {
.map(profile -> new TicketedProfile(profile, ticket(profile))); .map(profile -> new TicketedProfile(profile, ticket(profile)));
} }
// Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section.
private String ticket(PublicProfile profile) { private String ticket(PublicProfile profile) {
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id) List<String> tileIds = profile.tiles().stream().map(PublicProfile.Tile::id).toList();
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX))); return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(),
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList(); ProfileSections.statGroups(tileIds), ProfileSections.of(tileIds));
List<String> sections = new ArrayList<>(byStats.get(false));
if (!statGroups.isEmpty()) {
sections.add("STATS");
}
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections);
} }
} }
@@ -1,7 +1,9 @@
package auth_service.modules.registration.api; package auth_service.modules.registration.api;
import auth_service.modules.registration.api.dto.mapper.ApiMapper; import auth_service.modules.registration.api.dto.mapper.ApiMapper;
import auth_service.modules.registration.api.dto.request.ConfirmationRequest;
import auth_service.modules.registration.api.dto.request.RegistrationRequest; import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.api.dto.request.ResendConfirmationRequest;
import auth_service.modules.registration.api.dto.response.RegistrationResponse; import auth_service.modules.registration.api.dto.response.RegistrationResponse;
import auth_service.modules.registration.service.RegistrationService; import auth_service.modules.registration.service.RegistrationService;
import auth_service.shared.api.ClientIp; import auth_service.shared.api.ClientIp;
@@ -28,4 +30,16 @@ public class RegistrationController {
var userId = registrationService.register(ApiMapper.mapToModel(request), ClientIp.of(httpRequest)); var userId = registrationService.register(ApiMapper.mapToModel(request), ClientIp.of(httpRequest));
return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build()); return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build());
} }
@PostMapping(value = "/confirm", consumes = "application/json")
public ResponseEntity<Void> confirm(@Valid @RequestBody ConfirmationRequest request, HttpServletRequest httpRequest) {
registrationService.confirm(request.token(), request.password(), ClientIp.of(httpRequest));
return ResponseEntity.noContent().build();
}
@PostMapping(value = "/resend", consumes = "application/json")
public ResponseEntity<Void> resend(@Valid @RequestBody ResendConfirmationRequest request, HttpServletRequest httpRequest) {
registrationService.resend(request.email(), ClientIp.of(httpRequest));
return ResponseEntity.noContent().build();
}
} }
@@ -1,6 +1,7 @@
package auth_service.modules.registration.api; package auth_service.modules.registration.api;
import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.shared.api.dto.response.ErrorResponse;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.PendingLimitReachedException; import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
@@ -51,6 +52,8 @@ public class RegistrationExceptionController {
return response(body, HttpStatus.CONFLICT); return response(body, HttpStatus.CONFLICT);
} }
@ExceptionHandler(ConfirmationInvalidException.class)
public ResponseEntity<ErrorResponse> clientError(ConfirmationInvalidException ex) {
return response(new ErrorResponse("CONFIRMATION_INVALID", ex.getMessage()), HttpStatus.GONE);
}
} }
@@ -10,7 +10,6 @@ public abstract class ApiMapper {
public static Registration mapToModel(RegistrationRequest registrationRequest) { public static Registration mapToModel(RegistrationRequest registrationRequest) {
return Registration.builder(). return Registration.builder().
email(registrationRequest.email()). email(registrationRequest.email()).
rawPassword(registrationRequest.password()).
displayName(registrationRequest.displayName()). displayName(registrationRequest.displayName()).
language(registrationRequest.language()). language(registrationRequest.language()).
build(); build();
@@ -0,0 +1,28 @@
package auth_service.modules.registration.api.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.nio.charset.StandardCharsets;
/**
* The password is set here, by the owner of the address, and never at registration.
*/
public record ConfirmationRequest(@NotBlank @Size(max = 64) String token, @NotBlank @Size(min = 8) String password) {
/**
* BCrypt uses only the first 72 bytes and refuses a longer password.
*/
@AssertTrue(message = "The password may have at most 72 bytes")
@JsonIgnore
public boolean isPasswordShortEnough() {
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
}
@Override
public String toString() {
return "ConfirmationRequest[]";
}
}
@@ -2,22 +2,18 @@ package auth_service.modules.registration.api.dto.request;
import auth_service.shared.api.validation.DisplayNameFormat; import auth_service.shared.api.validation.DisplayNameFormat;
import auth_service.shared.account.EmailAddress; import auth_service.shared.account.EmailAddress;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Builder; import lombok.Builder;
import java.nio.charset.StandardCharsets;
/** /**
* No Steam ID: it comes only from "Sign in through Steam", later in the settings. * No Steam ID: it comes only from "Sign in through Steam", later in the settings. No password: the address owner
* sets it when confirming the address.
*/ */
@Builder @Builder
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email, public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
@NotBlank @Size(min = 8) String password,
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName, @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
// For the mails; null = never chosen. // For the mails; null = never chosen.
@Pattern(regexp = "de|en") String language) { @Pattern(regexp = "de|en") String language) {
@@ -25,13 +21,4 @@ public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_
public RegistrationRequest { public RegistrationRequest {
email = EmailAddress.normalize(email); email = EmailAddress.normalize(email);
} }
/**
* BCrypt uses only the first 72 bytes and refuses a longer password.
*/
@AssertTrue(message = "The password may have at most 72 bytes")
@JsonIgnore
public boolean isPasswordShortEnough() {
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
}
} }
@@ -0,0 +1,13 @@
package auth_service.modules.registration.api.dto.request;
import auth_service.shared.account.EmailAddress;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ResendConfirmationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email) {
public ResendConfirmationRequest {
email = EmailAddress.normalize(email);
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.exception;
public class ConfirmationInvalidException extends RuntimeException {
public ConfirmationInvalidException() {
super("The confirmation link is unknown, used or expired.");
}
}
@@ -2,16 +2,11 @@ package auth_service.modules.registration.model;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.ToString;
@Builder @Builder
@Data @Data
public class Registration { public class Registration {
private String email; private String email;
@ToString.Exclude
private String rawPassword;
@ToString.Exclude
private String encryptedPassword;
private String displayName; private String displayName;
private String language; private String language;
} }
@@ -2,15 +2,32 @@ package auth_service.modules.registration.persistence;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import java.time.Instant;
import java.util.Optional;
public interface RegisteredUserRepository { public interface RegisteredUserRepository {
boolean existsByEmail(String email); boolean existsByEmail(String email);
boolean existsByDisplayName(String displayName); boolean existsByDisplayName(String displayName);
/** /**
* Stores the user as PENDING: an admin accepts or denies the registration. * Stores the user as UNCONFIRMED: the address owner confirms by link, then an admin accepts or denies it.
*/ */
long save(Registration registration); long save(Registration registration);
long countPending(); /**
* UNCONFIRMED and PENDING: the requests that wait for the owner or the admin.
*/
long countWaiting();
Optional<Long> findUnconfirmedIdByEmail(String email);
void storeConfirmation(long userId, String tokenHash, Instant expiresAt);
/**
* Single use: sets PENDING and the password hash and clears the token hash.
*
* @return the user id, empty when the hash is unknown, used or expired
*/
Optional<Long> confirm(String tokenHash, String passwordHash, Instant now);
} }
@@ -7,8 +7,11 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Clock; import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Optional;
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -32,9 +35,9 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
@Transactional @Transactional
public long save(Registration registration) { public long save(Registration registration) {
Long id = jdbc.queryForObject(""" Long id = jdbc.queryForObject("""
INSERT INTO users (email, password, enabled, display_name, registration_date, status) INSERT INTO users (email, enabled, display_name, registration_date, status)
VALUES (?, ?, TRUE, ?, ?, 'PENDING') RETURNING id""", Long.class, VALUES (?, TRUE, ?, ?, 'UNCONFIRMED') RETURNING id""", Long.class,
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(), registration.getEmail(), registration.getDisplayName(),
LocalDateTime.now(Clock.systemUTC())); LocalDateTime.now(Clock.systemUTC()));
if (id == null) { if (id == null) {
throw new IllegalStateException("No id for the new user"); throw new IllegalStateException("No id for the new user");
@@ -46,8 +49,28 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
} }
@Override @Override
public long countPending() { public long countWaiting() {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status = 'PENDING'", Long.class); Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status IN ('UNCONFIRMED', 'PENDING')", Long.class);
return count != null ? count : 0L; return count != null ? count : 0L;
} }
@Override
public Optional<Long> findUnconfirmedIdByEmail(String email) {
return jdbc.queryForList("SELECT id FROM users WHERE email = ? AND status = 'UNCONFIRMED'", Long.class,
EmailAddress.normalize(email)).stream().findFirst();
}
@Override
public void storeConfirmation(long userId, String tokenHash, Instant expiresAt) {
jdbc.update("UPDATE users SET email_confirmation_hash = ?, email_confirmation_expires_at = ? WHERE id = ?",
tokenHash, Timestamp.from(expiresAt), userId);
}
@Override
public Optional<Long> confirm(String tokenHash, String passwordHash, Instant now) {
return jdbc.queryForList("""
UPDATE users SET status = 'PENDING', password = ?, email_confirmation_hash = NULL, email_confirmation_expires_at = NULL
WHERE email_confirmation_hash = ? AND email_confirmation_expires_at > ? AND status = 'UNCONFIRMED'
RETURNING id""", Long.class, passwordHash, tokenHash, Timestamp.from(now)).stream().findFirst();
}
} }
@@ -18,6 +18,10 @@ public class RegistrationAttemptService {
static final Duration LOCK_DURATION = Duration.ofMinutes(15); static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION); private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
static final int MAX_CONFIRMATION_MAILS_PER_ADDRESS = 3;
static final Duration CONFIRMATION_MAIL_WINDOW = Duration.ofHours(1);
private final AttemptLimiter confirmationMails = new AttemptLimiter(CONFIRMATION_MAIL_WINDOW);
private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION); private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION);
public void acquire(String clientIp) { public void acquire(String clientIp) {
@@ -38,4 +42,12 @@ public class RegistrationAttemptService {
throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds()); throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds());
} }
} }
/**
* At most 3 confirmation mails per address and hour over all IPs, so nobody can fill a stranger's inbox or the
* daily mail budget. The caller answers the same when it is false.
*/
public boolean tryConfirmationMail(String email) {
return confirmationMails.tryAcquire(email, MAX_CONFIRMATION_MAILS_PER_ADDRESS);
}
} }
@@ -1,17 +1,23 @@
package auth_service.modules.registration.service; package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException; import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
@Service @Service
@Public @Public
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -23,32 +29,71 @@ public class RegistrationService {
private final AccountMailer accountMailer; private final AccountMailer accountMailer;
static final int MAX_PENDING = 20; static final int MAX_PENDING = 20;
static final Duration CONFIRMATION_TTL = Duration.ofHours(24);
/** /**
* Stores a request that waits for the admin. When MAX_PENDING wait already, it is refused and counts as a failed * Stores an unconfirmed request without a password and mails the confirmation link. The address owner sets the
* attempt of the IP. * password when confirming; the admin sees the request after that. When MAX_PENDING requests wait already, it is
* refused and counts as a failed attempt of the IP. An address that is already unconfirmed gets a new link and no
* second row.
*/ */
// The user and the mail rows commit together. // The user and the mail rows commit together.
@Transactional @Transactional
public long register(Registration registration, String clientIp) { public long register(Registration registration, String clientIp) {
registrationAttemptService.acquire(clientIp); registrationAttemptService.acquire(clientIp);
Optional<Long> unconfirmed = registeredUserRepository.findUnconfirmedIdByEmail(registration.getEmail());
if (unconfirmed.isPresent()) {
registrationAttemptService.release(clientIp);
registrationAttemptService.countRequest(clientIp);
sendConfirmation(unconfirmed.get(), registration.getEmail());
return unconfirmed.get();
}
// ponytail: two requests at the same time can pass at 19 and make 21; a pg_advisory_xact_lock would make it // ponytail: two requests at the same time can pass at 19 and make 21; a pg_advisory_xact_lock would make it
// exact, but the limit is a brake, not a quota. // exact, but the limit is a brake, not a quota.
if (registeredUserRepository.countPending() >= MAX_PENDING) { if (registeredUserRepository.countWaiting() >= MAX_PENDING) {
throw new PendingLimitReachedException(); throw new PendingLimitReachedException();
} }
validate(registration); validate(registration);
registrationAttemptService.release(clientIp); registrationAttemptService.release(clientIp);
registrationAttemptService.countRequest(clientIp); registrationAttemptService.countRequest(clientIp);
var rawPassword = registration.getRawPassword();
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
long userId = registeredUserRepository.save(registration); long userId = registeredUserRepository.save(registration);
accountMailer.registrationRequested(userId); sendConfirmation(userId, registration.getEmail());
return userId; return userId;
} }
/**
* Sets the password of the account. A failed confirmation counts as a failed attempt of the IP.
*/
@Transactional
public void confirm(String rawToken, String rawPassword, String clientIp) {
registrationAttemptService.acquire(clientIp);
long userId = registeredUserRepository
.confirm(OpaqueToken.hash(rawToken), passwordEncoder.encode(rawPassword), Instant.now())
.orElseThrow(ConfirmationInvalidException::new);
registrationAttemptService.release(clientIp);
accountMailer.registrationRequested(userId);
}
/**
* Never fails for an unknown address: the answer must not tell whether it exists.
*/
@Transactional
public void resend(String email, String clientIp) {
registrationAttemptService.countRequest(clientIp);
registeredUserRepository.findUnconfirmedIdByEmail(email).ifPresent(userId -> sendConfirmation(userId, email));
}
// A new link replaces the old one. Over the per-address limit nothing is sent and nothing tells the caller.
private void sendConfirmation(long userId, String email) {
if (!registrationAttemptService.tryConfirmationMail(email)) {
return;
}
String token = OpaqueToken.generate();
registeredUserRepository.storeConfirmation(userId, OpaqueToken.hash(token), Instant.now().plus(CONFIRMATION_TTL));
accountMailer.confirmEmail(userId, token);
}
private void validate(Registration registration) { private void validate(Registration registration) {
if (registeredUserRepository.existsByEmail(registration.getEmail())) { if (registeredUserRepository.existsByEmail(registration.getEmail())) {
throw new EmailAlreadyInUseException("The provided email address is already in use."); throw new EmailAlreadyInUseException("The provided email address is already in use.");
@@ -0,0 +1,29 @@
package auth_service.modules.stats.api;
import auth_service.modules.stats.api.dto.PeopleDto;
import auth_service.modules.stats.api.mapper.ApiMapper;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.modules.stats.service.UserListService;
import auth_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class UserListController {
private final UserListService userListService;
@GetMapping("/users")
public ResponseEntity<PeopleDto> getUsers(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam UserCountKey key,
@RequestParam(defaultValue = "0") int page) {
return ResponseEntity.ok(ApiMapper.toApi(userListService.users(principal.getIdAsLong(), key, page)));
}
}
@@ -0,0 +1,14 @@
package auth_service.modules.stats.api.dto;
import java.util.List;
/**
* Who stands behind a number. Never an id, avatar or Steam ID (#26): a private user is only in privateCount, the
* viewer only in includesYou.
*/
public record PeopleDto(List<Person> listed, long listedTotal, long privateCount, boolean includesYou, int page,
int totalPages) {
public record Person(String displayName) {
}
}
@@ -1,12 +1,19 @@
package auth_service.modules.stats.api.mapper; package auth_service.modules.stats.api.mapper;
import auth_service.modules.stats.api.dto.PeopleDto;
import auth_service.modules.stats.api.dto.PublicStatsDto; import auth_service.modules.stats.api.dto.PublicStatsDto;
import auth_service.modules.stats.model.PeopleList;
import auth_service.modules.stats.model.PublicStats; import auth_service.modules.stats.model.PublicStats;
public abstract class ApiMapper { public abstract class ApiMapper {
private ApiMapper() { private ApiMapper() {
} }
public static PeopleDto toApi(PeopleList people) {
return new PeopleDto(people.listed().stream().map(PeopleDto.Person::new).toList(), people.listedTotal(),
people.privateCount(), people.includesYou(), people.page(), people.totalPages());
}
public static PublicStatsDto toApi(PublicStats stats) { public static PublicStatsDto toApi(PublicStats stats) {
return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked()); return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked());
} }
@@ -0,0 +1,8 @@
package auth_service.modules.stats.model;
/**
* @param others the users of a set without the viewer
* @param listed those of them shown by name
*/
public record PeopleCounts(long others, long listed, boolean includesYou) {
}
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more