Public Access
Compare commits
12
Commits
6e7fb1e066
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4701cebece | ||
|
|
c124d0e5d2 | ||
|
|
c362e7047e | ||
|
|
734ff2e07c | ||
|
|
d842d9e865 | ||
|
|
d924c09953 | ||
|
|
d9b816904a | ||
|
|
395b8e6f99 | ||
|
|
365b69feb4 | ||
|
|
3410cbc598 | ||
|
|
19463a1a4d | ||
|
|
5a2552ddf6 |
No files matched your search
@@ -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:
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,34 +1,42 @@
|
|||||||
# 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 email confirmation (a single-use link, valid 24 hours, on whose page the owner chooses the
|
- 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
|
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
|
||||||
@@ -56,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);
|
||||||
@@ -64,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
|
||||||
@@ -76,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
|
||||||
@@ -85,7 +109,7 @@ To Be Played is split into three repositories:
|
|||||||
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 "confirm your address" (`CONFIRM_EMAIL`, sent first; the admin mail follows the click), 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),
|
- **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.
|
and sends it to all active users or to picked ones, each in their app language. Every active user gets it.
|
||||||
@@ -108,6 +132,9 @@ To Be Played is split into three repositories:
|
|||||||
| `POST` | `/api/registration/confirm` (`{token, password}`; `204`; `410 CONFIRMATION_INVALID`) | public |
|
| `POST` | `/api/registration/confirm` (`{token, password}`; `204`; `410 CONFIRMATION_INVALID`) | public |
|
||||||
| `POST` | `/api/registration/resend` (`{email}`; always `204`) | 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 |
|
||||||
@@ -118,21 +145,27 @@ To Be Played is split into three repositories:
|
|||||||
| `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?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`, also for an unconfirmed request) | 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`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||||||
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||||
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token |
|
| `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 |
|
||||||
@@ -145,19 +178,21 @@ To Be Played is split into three repositories:
|
|||||||
| `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
|
||||||
|
|
||||||
@@ -176,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
|
||||||
@@ -219,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
|
||||||
@@ -232,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.
|
||||||
|
|
||||||
@@ -301,7 +336,7 @@ sequenceDiagram
|
|||||||
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
|
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
|
||||||
@@ -339,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 / name change (commit)
|
A->>A: link / unlink / block / unblock / name change (commit)
|
||||||
A->>C: POST /internal/accounts/states {userId, steamId, displayName, 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, display name in account_name
|
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)
|
||||||
```
|
```
|
||||||
@@ -371,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
|
||||||
@@ -391,18 +453,24 @@ 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`) |
|
||||||
@@ -419,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
-13
@@ -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, String displayName, 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,9 +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.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;
|
||||||
@@ -50,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);
|
||||||
}
|
}
|
||||||
@@ -61,17 +118,41 @@ 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.getString("display_name"),
|
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
|
||||||
!rs.getBoolean("enabled"));
|
!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, display_name, 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, display_name, 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,6 +65,19 @@ 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);
|
||||||
@@ -71,9 +91,21 @@ public class AdminUserController {
|
|||||||
return ResponseEntity.noContent().build();
|
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, TARGET_IS_PENDING, TARGET_IS_UNCONFIRMED or NOT_UNCONFIRMED
|
* 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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -111,6 +113,19 @@ public class AdminUserService {
|
|||||||
log.info("Admin {} deleted the unconfirmed registration of user {}", actorUserId, targetUserId);
|
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");
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -82,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,52 @@ public enum MailTemplate {
|
|||||||
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.
|
// Admin text only: %1$s subject, %2$s body.
|
||||||
BROADCAST(
|
BROADCAST(
|
||||||
"%1$s", """
|
"%1$s", """
|
||||||
|
|||||||
@@ -72,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth_service.modules.stats.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of a user list: names of the listed users only, the rest as a count.
|
||||||
|
*/
|
||||||
|
public record PeopleList(List<String> listed, long listedTotal, long privateCount, boolean includesYou, int page,
|
||||||
|
int totalPages) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth_service.modules.stats.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user numbers of the landing page; each has a list behind it.
|
||||||
|
*/
|
||||||
|
public enum UserCountKey {
|
||||||
|
TOTAL_USERS,
|
||||||
|
STEAM_LINKED
|
||||||
|
}
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
package auth_service.modules.stats.repo;
|
package auth_service.modules.stats.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.model.PeopleCounts;
|
||||||
|
import auth_service.modules.stats.model.UserCountKey;
|
||||||
import auth_service.shared.account.ActiveUser;
|
import auth_service.shared.account.ActiveUser;
|
||||||
|
import auth_service.shared.account.ListedUser;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class StatsRepository {
|
public class StatsRepository {
|
||||||
|
|
||||||
|
// One WHERE per number: the count and its list read the same set.
|
||||||
|
private static final Map<UserCountKey, String> SETS = Map.of(
|
||||||
|
UserCountKey.TOTAL_USERS, ActiveUser.of("u"),
|
||||||
|
UserCountKey.STEAM_LINKED, ActiveUser.of("u") + " AND u.steam_id IS NOT NULL");
|
||||||
|
|
||||||
|
// LEFT JOIN: a user without a settings row is in the number, and private.
|
||||||
|
private static final String FROM = " FROM users u LEFT JOIN user_settings s ON s.user_id = u.id WHERE ";
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -16,13 +30,32 @@ public class StatsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public long countTotalUsers() {
|
public long countTotalUsers() {
|
||||||
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class);
|
return count(UserCountKey.TOTAL_USERS);
|
||||||
return v != null ? v : 0L;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countUsersWithSteamLinked() {
|
public long countUsersWithSteamLinked() {
|
||||||
Long v = jdbc.queryForObject(
|
return count(UserCountKey.STEAM_LINKED);
|
||||||
"SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class);
|
}
|
||||||
|
|
||||||
|
private long count(UserCountKey key) {
|
||||||
|
Long v = jdbc.queryForObject("SELECT COUNT(*)" + FROM + SETS.get(key), Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PeopleCounts countUsers(UserCountKey key, long viewerId) {
|
||||||
|
return jdbc.queryForObject("SELECT COUNT(*) FILTER (WHERE u.id <> ?) AS others, "
|
||||||
|
+ "COUNT(*) FILTER (WHERE u.id <> ? AND " + ListedUser.of("u", "s") + ") AS listed, "
|
||||||
|
+ "COALESCE(BOOL_OR(u.id = ?), FALSE) AS you" + FROM + SETS.get(key),
|
||||||
|
(rs, row) -> new PeopleCounts(rs.getLong("others"), rs.getLong("listed"), rs.getBoolean("you")),
|
||||||
|
viewerId, viewerId, viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the display name and nothing else, so no id can reach a response.
|
||||||
|
*/
|
||||||
|
public List<String> listedUserNames(UserCountKey key, long viewerId, int offset, int limit) {
|
||||||
|
return jdbc.queryForList("SELECT u.display_name" + FROM + SETS.get(key) + " AND u.id <> ? AND "
|
||||||
|
+ ListedUser.of("u", "s") + " ORDER BY LOWER(u.display_name), u.id LIMIT ? OFFSET ?",
|
||||||
|
String.class, viewerId, limit, offset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package auth_service.modules.stats.service;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.model.PeopleCounts;
|
||||||
|
import auth_service.modules.stats.model.PeopleList;
|
||||||
|
import auth_service.modules.stats.model.UserCountKey;
|
||||||
|
import auth_service.modules.stats.repo.StatsRepository;
|
||||||
|
import auth_service.shared.security.RequiresRole;
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not cached: an opt-out shows on the next load.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserListService {
|
||||||
|
|
||||||
|
static final int PAGE_SIZE = 40;
|
||||||
|
static final int MAX_PAGE = 250;
|
||||||
|
|
||||||
|
private final StatsRepository statsRepository;
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
public PeopleList users(long viewerId, UserCountKey key, int page) {
|
||||||
|
int clamped = Math.clamp(page, 0, MAX_PAGE);
|
||||||
|
PeopleCounts counts = statsRepository.countUsers(key, viewerId);
|
||||||
|
int totalPages = (int) ((counts.listed() + PAGE_SIZE - 1) / PAGE_SIZE);
|
||||||
|
List<String> names = statsRepository.listedUserNames(key, viewerId, clamped * PAGE_SIZE, PAGE_SIZE);
|
||||||
|
return new PeopleList(names, counts.listed(), counts.others() - counts.listed(), counts.includesYou(), clamped,
|
||||||
|
totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import auth_service.shared.security.UserPrincipal;
|
|||||||
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
||||||
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
|
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
|
||||||
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
|
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
|
||||||
|
import auth_service.modules.user.api.dto.request.OwnerListsRequest;
|
||||||
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
|
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
|
||||||
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
|
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
|
||||||
|
import auth_service.modules.user.api.dto.request.StatChartRequest;
|
||||||
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
|
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
|
||||||
import auth_service.modules.user.api.dto.response.UserProfileResponse;
|
import auth_service.modules.user.api.dto.response.UserProfileResponse;
|
||||||
import auth_service.modules.user.api.mapper.ApiMapper;
|
import auth_service.modules.user.api.mapper.ApiMapper;
|
||||||
@@ -57,6 +59,13 @@ public class UserController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/settings/stat-chart")
|
||||||
|
public ResponseEntity<Void> updateStatChart(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody StatChartRequest request) {
|
||||||
|
this.userService.updateStatChart(principal.getIdAsLong(), request.group(), request.chart());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/favorite-platform")
|
@PutMapping(value = "/settings/favorite-platform")
|
||||||
public ResponseEntity<Void> updateFavoritePlatform(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> updateFavoritePlatform(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody FavoritePlatformRequest request) {
|
@Valid @RequestBody FavoritePlatformRequest request) {
|
||||||
@@ -64,6 +73,13 @@ public class UserController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/settings/owner-lists")
|
||||||
|
public ResponseEntity<Void> updateOwnerLists(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody OwnerListsRequest request) {
|
||||||
|
this.userService.updateOwnerLists(principal.getIdAsLong(), request.enabled());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/welcome-seen")
|
@PutMapping(value = "/settings/welcome-seen")
|
||||||
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
this.userService.markWelcomeSeen(principal.getIdAsLong());
|
this.userService.markWelcomeSeen(principal.getIdAsLong());
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record OwnerListsRequest(@NotNull Boolean enabled) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
|
import auth_service.modules.user.model.StatCharts;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param group a stat group of the Frontend
|
||||||
|
* @param chart the form for the group; null puts the group back to its default
|
||||||
|
*/
|
||||||
|
public record StatChartRequest(@NotNull @Pattern(regexp = "[A-Z_]{1,20}") String group,
|
||||||
|
@Pattern(regexp = "[A-Z]{1,10}") String chart) {
|
||||||
|
|
||||||
|
@AssertTrue(message = "Unknown stat group or chart")
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isKnownChoice() {
|
||||||
|
return group == null || (chart == null ? StatCharts.knows(group) : StatCharts.allows(group, chart));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import lombok.Builder;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
|
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
|
||||||
|
boolean showInOwnerLists,
|
||||||
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
|
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
|
||||||
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
|
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
|
||||||
String favoritePlatform, Instant welcomeSeenAt) {
|
Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) {
|
||||||
}
|
}
|
||||||
@@ -42,11 +42,13 @@ public abstract class ApiMapper {
|
|||||||
.useDarkTheme(user.getSettings().getTheme().equals("dark"))
|
.useDarkTheme(user.getSettings().getTheme().equals("dark"))
|
||||||
.language(user.getSettings().getLanguage())
|
.language(user.getSettings().getLanguage())
|
||||||
.publicProfile(user.getSettings().isPublicProfile())
|
.publicProfile(user.getSettings().isPublicProfile())
|
||||||
|
.showInOwnerLists(user.getSettings().isShowInOwnerLists())
|
||||||
.appearance(toApi(user.getSettings().getAppearance()))
|
.appearance(toApi(user.getSettings().getAppearance()))
|
||||||
.profileStyle(toApi(user.getSettings().getProfileStyle()))
|
.profileStyle(toApi(user.getSettings().getProfileStyle()))
|
||||||
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
|
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
|
||||||
: toApi(user.getSettings().getDashboardTiles()))
|
: toApi(user.getSettings().getDashboardTiles()))
|
||||||
.profileTiles(toApi(user.getSettings().getProfileTiles()))
|
.profileTiles(toApi(user.getSettings().getProfileTiles()))
|
||||||
|
.statCharts(user.getSettings().getStatCharts())
|
||||||
.favoritePlatform(user.getSettings().getFavoritePlatform())
|
.favoritePlatform(user.getSettings().getFavoritePlatform())
|
||||||
.welcomeSeenAt(user.getSettings().getWelcomeSeenAt())
|
.welcomeSeenAt(user.getSettings().getWelcomeSeenAt())
|
||||||
.build())
|
.build())
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package auth_service.modules.user.model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chart form a user picked per stat group. Keep the allowed forms in line with the Frontend
|
||||||
|
* {@code utils/statCharts.js}.
|
||||||
|
*/
|
||||||
|
public final class StatCharts {
|
||||||
|
|
||||||
|
private static final Map<String, Set<String>> ALLOWED = Map.of(
|
||||||
|
"TREND", Set.of("LINE", "BARS"),
|
||||||
|
"FINISHED", Set.of("BARS", "LINE", "LIST"),
|
||||||
|
"TASTE", Set.of("BARS", "PIE", "DONUT", "LIST"),
|
||||||
|
"PLATFORMS", Set.of("BARS", "PIE", "DONUT", "LIST"),
|
||||||
|
"TOP_GAMES", Set.of("LIST", "BARS", "PIE", "DONUT"),
|
||||||
|
"ACTIVITY", Set.of("BARS", "LINE"),
|
||||||
|
"ACTIVITY_GAMES", Set.of("LIST", "BARS", "PIE", "DONUT"));
|
||||||
|
|
||||||
|
private StatCharts() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean knows(String group) {
|
||||||
|
return ALLOWED.containsKey(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean allows(String group, String chart) {
|
||||||
|
return ALLOWED.getOrDefault(group, Set.of()).contains(chart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param stored "GROUP:CHART" entries, as the column holds them; an entry without ":" is skipped
|
||||||
|
*/
|
||||||
|
public static Map<String, String> parse(List<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ArrayList<String> stored(Map<String, String> charts) {
|
||||||
|
ArrayList<String> entries = new ArrayList<>();
|
||||||
|
charts.forEach((group, chart) -> entries.add(group + ":" + chart));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import lombok.Data;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -12,6 +13,8 @@ public class UserSettings {
|
|||||||
private String language;
|
private String language;
|
||||||
private String theme;
|
private String theme;
|
||||||
private boolean publicProfile;
|
private boolean publicProfile;
|
||||||
|
/** Listed by name only together with publicProfile, see ListedUser. */
|
||||||
|
private boolean showInOwnerLists;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Appearance appearance = Appearance.DEFAULT;
|
private Appearance appearance = Appearance.DEFAULT;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@@ -20,6 +23,9 @@ public class UserSettings {
|
|||||||
private List<Tile> dashboardTiles;
|
private List<Tile> dashboardTiles;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
|
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
|
||||||
|
/** Stat group to chart form; a group not in it shows its default. */
|
||||||
|
@Builder.Default
|
||||||
|
private Map<String, String> statCharts = Map.of();
|
||||||
private String favoritePlatform;
|
private String favoritePlatform;
|
||||||
/** Null: the welcome page was not seen yet. */
|
/** Null: the welcome page was not seen yet. */
|
||||||
private Instant welcomeSeenAt;
|
private Instant welcomeSeenAt;
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class UserSettingsEntity {
|
|||||||
@Column(name = "public_profile", nullable = false)
|
@Column(name = "public_profile", nullable = false)
|
||||||
private boolean publicProfile;
|
private boolean publicProfile;
|
||||||
|
|
||||||
|
@Column(name = "show_in_owner_lists", nullable = false)
|
||||||
|
private boolean showInOwnerLists;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String accent;
|
private String accent;
|
||||||
|
|
||||||
@@ -77,6 +80,11 @@ public class UserSettingsEntity {
|
|||||||
@Column(name = "profile_tiles", nullable = false)
|
@Column(name = "profile_tiles", nullable = false)
|
||||||
private List<String> profileTiles;
|
private List<String> profileTiles;
|
||||||
|
|
||||||
|
/** "GROUP:CHART" entries, see {@link auth_service.modules.user.model.StatCharts}. */
|
||||||
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
|
@Column(name = "stat_charts", nullable = false)
|
||||||
|
private List<String> statCharts;
|
||||||
|
|
||||||
@Column(name = "welcome_seen_at")
|
@Column(name = "welcome_seen_at")
|
||||||
private Instant welcomeSeenAt;
|
private Instant welcomeSeenAt;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence.mapper;
|
|||||||
|
|
||||||
import auth_service.modules.user.model.Appearance;
|
import auth_service.modules.user.model.Appearance;
|
||||||
import auth_service.modules.user.model.ProfileStyle;
|
import auth_service.modules.user.model.ProfileStyle;
|
||||||
|
import auth_service.modules.user.model.StatCharts;
|
||||||
import auth_service.modules.user.model.Tile;
|
import auth_service.modules.user.model.Tile;
|
||||||
import auth_service.modules.user.model.User;
|
import auth_service.modules.user.model.User;
|
||||||
import auth_service.modules.user.model.UserSettings;
|
import auth_service.modules.user.model.UserSettings;
|
||||||
@@ -27,6 +28,7 @@ public abstract class EntityMapper {
|
|||||||
.language(settings.getLanguage())
|
.language(settings.getLanguage())
|
||||||
.theme(settings.getTheme())
|
.theme(settings.getTheme())
|
||||||
.publicProfile(settings.isPublicProfile())
|
.publicProfile(settings.isPublicProfile())
|
||||||
|
.showInOwnerLists(settings.isShowInOwnerLists())
|
||||||
.appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(),
|
.appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(),
|
||||||
settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(),
|
settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(),
|
||||||
settings.isReduceMotion()))
|
settings.isReduceMotion()))
|
||||||
@@ -35,6 +37,7 @@ public abstract class EntityMapper {
|
|||||||
.dashboardTiles(settings.getDashboardTiles() == null ? null
|
.dashboardTiles(settings.getDashboardTiles() == null ? null
|
||||||
: settings.getDashboardTiles().stream().map(Tile::parse).toList())
|
: settings.getDashboardTiles().stream().map(Tile::parse).toList())
|
||||||
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
|
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
|
||||||
|
.statCharts(StatCharts.parse(settings.getStatCharts()))
|
||||||
.favoritePlatform(settings.getFavoritePlatform())
|
.favoritePlatform(settings.getFavoritePlatform())
|
||||||
.welcomeSeenAt(settings.getWelcomeSeenAt())
|
.welcomeSeenAt(settings.getWelcomeSeenAt())
|
||||||
.build();
|
.build();
|
||||||
@@ -59,6 +62,7 @@ public abstract class EntityMapper {
|
|||||||
target.setLanguage(settings.getLanguage());
|
target.setLanguage(settings.getLanguage());
|
||||||
target.setTheme(settings.getTheme());
|
target.setTheme(settings.getTheme());
|
||||||
target.setPublicProfile(settings.isPublicProfile());
|
target.setPublicProfile(settings.isPublicProfile());
|
||||||
|
target.setShowInOwnerLists(settings.isShowInOwnerLists());
|
||||||
Appearance appearance = settings.getAppearance();
|
Appearance appearance = settings.getAppearance();
|
||||||
target.setAccent(appearance.accent());
|
target.setAccent(appearance.accent());
|
||||||
target.setCorners(appearance.corners());
|
target.setCorners(appearance.corners());
|
||||||
@@ -75,6 +79,7 @@ public abstract class EntityMapper {
|
|||||||
target.setDashboardTiles(settings.getDashboardTiles() == null ? null
|
target.setDashboardTiles(settings.getDashboardTiles() == null ? null
|
||||||
: Tile.storedAll(settings.getDashboardTiles()));
|
: Tile.storedAll(settings.getDashboardTiles()));
|
||||||
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
|
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
|
||||||
|
target.setStatCharts(StatCharts.stored(settings.getStatCharts()));
|
||||||
target.setFavoritePlatform(settings.getFavoritePlatform());
|
target.setFavoritePlatform(settings.getFavoritePlatform());
|
||||||
target.setWelcomeSeenAt(settings.getWelcomeSeenAt());
|
target.setWelcomeSeenAt(settings.getWelcomeSeenAt());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -51,15 +54,18 @@ public class UserService {
|
|||||||
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
|
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
|
||||||
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
|
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
|
||||||
}
|
}
|
||||||
boolean renamed = displayName != null && userRepository.findById(userId)
|
// The listing in Core depends on the name and the public profile.
|
||||||
.map(user -> !displayName.equals(user.getDisplayName())).orElse(true);
|
boolean listingChanged = userRepository.findById(userId)
|
||||||
|
.map(user -> !Objects.equals(displayName, user.getDisplayName())
|
||||||
|
|| user.getSettings().isPublicProfile() != userSettings.isPublicProfile())
|
||||||
|
.orElse(displayName != null || userSettings.isPublicProfile());
|
||||||
userRepository.update(userId, user -> {
|
userRepository.update(userId, user -> {
|
||||||
user.setDisplayName(displayName);
|
user.setDisplayName(displayName);
|
||||||
user.getSettings().setLanguage(userSettings.getLanguage());
|
user.getSettings().setLanguage(userSettings.getLanguage());
|
||||||
user.getSettings().setTheme(userSettings.getTheme());
|
user.getSettings().setTheme(userSettings.getTheme());
|
||||||
user.getSettings().setPublicProfile(userSettings.isPublicProfile());
|
user.getSettings().setPublicProfile(userSettings.isPublicProfile());
|
||||||
});
|
});
|
||||||
if (renamed) {
|
if (listingChanged) {
|
||||||
accountStatePublisher.publish(userId);
|
accountStatePublisher.publish(userId);
|
||||||
}
|
}
|
||||||
log.info("User settings updated for user {}", userId);
|
log.info("User settings updated for user {}", userId);
|
||||||
@@ -124,6 +130,25 @@ public class UserService {
|
|||||||
@CacheEvict(value = "users", key = "#userId")
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
public void updateProfileTiles(long userId, List<Tile> tiles) {
|
public void updateProfileTiles(long userId, List<Tile> tiles) {
|
||||||
userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
|
userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param chart null puts the group back to its default form
|
||||||
|
*/
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
|
public void updateStatChart(long userId, String group, String chart) {
|
||||||
|
userRepository.update(userId, user -> {
|
||||||
|
Map<String, String> charts = new LinkedHashMap<>(user.getSettings().getStatCharts());
|
||||||
|
if (chart == null) {
|
||||||
|
charts.remove(group);
|
||||||
|
} else {
|
||||||
|
charts.put(group, chart);
|
||||||
|
}
|
||||||
|
user.getSettings().setStatCharts(charts);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresRole(Role.USER)
|
@RequiresRole(Role.USER)
|
||||||
@@ -134,6 +159,14 @@ public class UserService {
|
|||||||
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
|
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
|
public void updateOwnerLists(long userId, boolean enabled) {
|
||||||
|
userRepository.update(userId, user -> user.getSettings().setShowInOwnerLists(enabled));
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresRole(Role.USER)
|
@RequiresRole(Role.USER)
|
||||||
@Transactional
|
@Transactional
|
||||||
@CacheEvict(value = "users", key = "#userId")
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package auth_service.shared.account;
|
package auth_service.shared.account;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure only warns,
|
* Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure is retried 3
|
||||||
* because the nightly cleanup sends every user again.
|
* times, then the nightly cleanup sends every user again.
|
||||||
*/
|
*/
|
||||||
public interface AccountStatePublisher {
|
public interface AccountStatePublisher {
|
||||||
void publish(long userId);
|
void publish(long userId);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every write that can remove an admin runs {@link #LOCK} first in its transaction, so two of them never both see
|
||||||
|
* a second admin who is about to go.
|
||||||
|
*/
|
||||||
|
public final class ActiveAdmins {
|
||||||
|
|
||||||
|
public static final String LOCK = "SELECT u.id FROM users u WHERE u.role = 'PLATFORM_ADMIN' AND "
|
||||||
|
+ ActiveUser.of("u") + " ORDER BY u.id FOR UPDATE";
|
||||||
|
|
||||||
|
private ActiveAdmins() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only rule that decides who is shown by name in owner and user lists (#26). Everyone else is private. The
|
||||||
|
* settings alias may come from a LEFT JOIN: no settings row is not listed.
|
||||||
|
*/
|
||||||
|
public final class ListedUser {
|
||||||
|
|
||||||
|
private ListedUser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String of(String user, String settings) {
|
||||||
|
return PublicUser.of(user, settings) + " AND " + settings + ".show_in_owner_lists";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port for a reset mail the user did not ask for, e.g. by an admin. False when the user is not active (cannot log
|
||||||
|
* in); then nothing is sent.
|
||||||
|
*/
|
||||||
|
public interface PasswordResetSender {
|
||||||
|
boolean sendByAdmin(long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile parts a user shows, by the names of the profile ticket. Core knows sections, not tiles: every
|
||||||
|
* {@code STATS_} tile is one group of the single STATS section.
|
||||||
|
*/
|
||||||
|
public final class ProfileSections {
|
||||||
|
|
||||||
|
private static final String STATS_PREFIX = "STATS_";
|
||||||
|
private static final String STATS = "STATS";
|
||||||
|
// The names Core stores; a tile id of any other kind is a user's own choice and says nothing to Core.
|
||||||
|
private static final Set<String> KNOWN = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", STATS);
|
||||||
|
|
||||||
|
private ProfileSections() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tileIds the tile ids without their size
|
||||||
|
*/
|
||||||
|
public static List<String> of(List<String> tileIds) {
|
||||||
|
List<String> sections = new ArrayList<>(tileIds.stream().filter(KNOWN::contains).toList());
|
||||||
|
if (tileIds.stream().anyMatch(id -> id.startsWith(STATS_PREFIX)) && !sections.contains(STATS)) {
|
||||||
|
sections.add(STATS);
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tileIds the tile ids without their size
|
||||||
|
* @return the stat groups of Core, one per {@code STATS_} tile
|
||||||
|
*/
|
||||||
|
public static List<String> statGroups(List<String> tileIds) {
|
||||||
|
return tileIds.stream().filter(id -> id.startsWith(STATS_PREFIX)).map(id -> id.substring(STATS_PREFIX.length()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule Core uses to allow follows (#27): active, public profile on, with a display name. It matches
|
||||||
|
* {@code PublicProfileRepository.findPublicByDisplayName}. The settings alias may come from a LEFT JOIN: no settings
|
||||||
|
* row is not public.
|
||||||
|
*/
|
||||||
|
public final class PublicUser {
|
||||||
|
|
||||||
|
private PublicUser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String of(String user, String settings) {
|
||||||
|
return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + user + ".display_name IS NOT NULL";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,4 +28,11 @@ public interface AccountMailer {
|
|||||||
* @param deletedFrom the first day the nightly cleanup may remove the account
|
* @param deletedFrom the first day the nightly cleanup may remove the account
|
||||||
*/
|
*/
|
||||||
void accountDeleted(long userId, LocalDate deletedFrom);
|
void accountDeleted(long userId, LocalDate deletedFrom);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link with the raw token; the caller stores only its hash.
|
||||||
|
*/
|
||||||
|
void passwordReset(long userId, String rawToken);
|
||||||
|
|
||||||
|
void passwordChanged(long userId);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package auth_service.shared.security;
|
package auth_service.shared.security;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port for the filter: the access token of a blocked or deleted user stops working before it expires.
|
* Port for the filter: the access token of a blocked or deleted user, or one from before a role change, stops
|
||||||
|
* working before it expires.
|
||||||
*/
|
*/
|
||||||
public interface ActiveUserCheck {
|
public interface ActiveUserCheck {
|
||||||
boolean isActive(long userId);
|
UserTokenState state(long userId);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package auth_service.shared.security;
|
package auth_service.shared.security;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port interface for token generation.
|
* Port interface for token generation.
|
||||||
* Decouples the login service from specific token implementations (e.g. JWT).
|
* Decouples the login service from specific token implementations (e.g. JWT).
|
||||||
@@ -13,6 +15,16 @@ public interface TokenGenerator {
|
|||||||
* @param role The active role of the login.
|
* @param role The active role of the login.
|
||||||
* @return The generated token string.
|
* @return The generated token string.
|
||||||
*/
|
*/
|
||||||
String generateToken(String userId, Long steamId, Role role);
|
default String generateToken(String userId, Long steamId, Role role) {
|
||||||
|
return generateToken(userId, steamId, role, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same, but the token is never issued before {@code notBefore}, so the filter's role change check accepts it
|
||||||
|
* at once (see {@link UserTokenState#accepts}).
|
||||||
|
*
|
||||||
|
* @param notBefore lower bound of the {@code iat}, null for none
|
||||||
|
*/
|
||||||
|
String generateToken(String userId, Long steamId, Role role, Instant notBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
package auth_service.shared.security;
|
package auth_service.shared.security;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param role the active role of this login ("view as"), never above the stored one
|
* @param role the active role of this login ("view as"), never above the stored one
|
||||||
|
* @param issuedAt {@code iat} of the token, null when it has none
|
||||||
*/
|
*/
|
||||||
public record UserPrincipal(
|
public record UserPrincipal(
|
||||||
String id,
|
String id,
|
||||||
Long steamId,
|
Long steamId,
|
||||||
Role role
|
Role role,
|
||||||
|
Instant issuedAt
|
||||||
) implements Principal {
|
) implements Principal {
|
||||||
|
|
||||||
|
public UserPrincipal(String id, Long steamId, Role role) {
|
||||||
|
this(id, steamId, role, null);
|
||||||
|
}
|
||||||
|
|
||||||
public UserPrincipal(String id, Long steamId) {
|
public UserPrincipal(String id, Long steamId) {
|
||||||
this(id, steamId, Role.USER);
|
this(id, steamId, Role.USER);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package auth_service.shared.security;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the filter needs to know about the user of an access token.
|
||||||
|
*
|
||||||
|
* @param roleChangedAt null when the role never changed on the admin page
|
||||||
|
*/
|
||||||
|
public record UserTokenState(boolean active, Instant roleChangedAt) {
|
||||||
|
|
||||||
|
public static final UserTokenState INACTIVE = new UserTokenState(false, null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A token issued before roleChangedAt is refused. roleChangedAt is the start of the second after the change.
|
||||||
|
*/
|
||||||
|
public boolean accepts(Instant issuedAt) {
|
||||||
|
return active && (roleChangedAt == null || (issuedAt != null && !issuedAt.isBefore(roleChangedAt)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- SHA-256 hex of the reset link token, NULL once used; the expiry is 1 h from the last send. A new send replaces it.
|
||||||
|
ALTER TABLE users ADD COLUMN password_reset_hash VARCHAR(64),
|
||||||
|
ADD COLUMN password_reset_expires_at TIMESTAMPTZ;
|
||||||
|
CREATE UNIQUE INDEX uq_users_password_reset_hash ON users (password_reset_hash)
|
||||||
|
WHERE password_reset_hash IS NOT NULL;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Target groups of an admin hint, combined with AND. All defaults = every user, so old hints keep working.
|
||||||
|
ALTER TABLE hint
|
||||||
|
ADD COLUMN target_no_steam BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- This role and higher.
|
||||||
|
ADD COLUMN target_role VARCHAR(16) CHECK (target_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')),
|
||||||
|
ADD COLUMN target_new_days SMALLINT CHECK (target_new_days BETWEEN 1 AND 365),
|
||||||
|
-- Checked by the Frontend with Core's library summary; Auth only sends the flag.
|
||||||
|
ADD COLUMN target_empty_library BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- The built-in hints; the texts live in the Frontend. A PLATFORM_ADMIN turns each on or off.
|
||||||
|
CREATE TABLE auto_hint (
|
||||||
|
hint_key VARCHAR(16) PRIMARY KEY,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
INSERT INTO auto_hint (hint_key) VALUES ('NO_STEAM'), ('EMPTY_LIBRARY'), ('PILE_GREW'), ('LINK_QUEUE');
|
||||||
|
|
||||||
|
-- hidden_until NULL: hidden for good ("Do not show again"); else hidden until then (X: 7 days).
|
||||||
|
CREATE TABLE auto_hint_hidden (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
hint_key VARCHAR(16) NOT NULL REFERENCES auto_hint (hint_key),
|
||||||
|
hidden_until TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (user_id, hint_key)
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- GROUP:CHART per changed stat group; a group not listed shows its default form
|
||||||
|
ALTER TABLE user_settings ADD COLUMN stat_charts VARCHAR(32)[] NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- NULL: the role never changed on the admin page. A login token issued before this time is refused.
|
||||||
|
ALTER TABLE users ADD COLUMN role_changed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Log of role changes on the admin page. No foreign keys: the log outlives a purged user.
|
||||||
|
CREATE TABLE role_change (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
actor_user_id BIGINT NOT NULL,
|
||||||
|
target_user_id BIGINT NOT NULL,
|
||||||
|
old_role VARCHAR(16) NOT NULL CHECK (old_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')),
|
||||||
|
new_role VARCHAR(16) NOT NULL CHECK (new_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')),
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_role_change_target ON role_change (target_user_id, changed_at DESC);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Core item #3: the new UP_NEXT dashboard tile, added once at the end of every saved layout. A NULL layout
|
||||||
|
-- already shows the Frontend default, which has it. One-shot: a user who removes the tile later keeps it removed.
|
||||||
|
UPDATE user_settings
|
||||||
|
SET dashboard_tiles = array_append(dashboard_tiles, 'UP_NEXT:M')
|
||||||
|
WHERE dashboard_tiles IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM unnest(dashboard_tiles) t WHERE t LIKE 'UP_NEXT:%');
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Opt-in to be shown by name in owner and user lists (#26). Off for every existing user; a user is listed only
|
||||||
|
-- together with the public profile.
|
||||||
|
ALTER TABLE user_settings ADD COLUMN show_in_owner_lists BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user