Public Access
Compare commits
17
Commits
e72222a37f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4701cebece | ||
|
|
c124d0e5d2 | ||
|
|
c362e7047e | ||
|
|
734ff2e07c | ||
|
|
d842d9e865 | ||
|
|
d924c09953 | ||
|
|
d9b816904a | ||
|
|
395b8e6f99 | ||
|
|
365b69feb4 | ||
|
|
3410cbc598 | ||
|
|
19463a1a4d | ||
|
|
5a2552ddf6 | ||
|
|
6e7fb1e066 | ||
|
|
1b7bafd9f0 | ||
|
|
0af76da9c7 | ||
|
|
878ede914b | ||
|
|
a60011ce9f |
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>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- testing -->
|
||||
<dependency>
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
# To Be Played – Auth Service
|
||||
|
||||
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the
|
||||
public profile, and it signs the JWTs that the Core service trusts.
|
||||
Identity service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
|
||||
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 |
|
||||
|---|---|
|
||||
| **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
|
||||
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||||
| ToBePlayed-Frontend | React single-page app |
|
||||
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||
|
||||
## Features
|
||||
|
||||
- Registration by admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
|
||||
- Registration by email confirmation (a single-use link, valid 24 hours, on whose page the owner chooses the
|
||||
password; unconfirmed requests are deleted after 7 days) and then admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
|
||||
lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit).
|
||||
- **Password reset**: "forgot password" mails a link (`/reset-password#<token>`, single use, valid 1 hour, only the
|
||||
SHA-256 stored, a new mail replaces the old link). Only an ACTIVE user gets a working link; every other address gets
|
||||
the same `204` and no mail. A reset ends all logins; "change password" (current password + new one) ends all logins
|
||||
but the current one. A PLATFORM_ADMIN can send the same mail to an active user. Both send a "password changed" mail.
|
||||
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations
|
||||
per IP, block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
|
||||
wrong password, and every failed login gets the same error.
|
||||
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
||||
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
||||
public key as a JWK, so Core finds the right key after a rotation.
|
||||
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
|
||||
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
|
||||
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
|
||||
token that comes back revokes all tokens of that login. Logout revokes them too.
|
||||
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
||||
frontend origin. A `Bearer` header still works for API clients and tests.
|
||||
- User settings: display name (unique, case-insensitive), language, dark mode.
|
||||
- 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
|
||||
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
||||
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
||||
foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but
|
||||
unverified: a verified link from another account takes them away. Unlinking is possible.
|
||||
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
|
||||
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
||||
- Appearance settings synced across devices: accent color, corner style, wide layout, reduce animations, background and
|
||||
dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
||||
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
|
||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
||||
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
|
||||
@@ -55,6 +64,14 @@ To Be Played is split into three repositories:
|
||||
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
|
||||
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
|
||||
refresh. Core checks the same claim.
|
||||
**Role change**: an admin sets the role of another user on the admin users page (`PUT /api/admin/users/{id}/role`).
|
||||
The change is a row in `role_change` (actor, old and new role, time; shown in the "Roles" dialog) and in
|
||||
`admin_audit` (`ROLE_CHANGE`), sets `users.role_changed_at` and clears "view as" of the open logins. An access
|
||||
token issued before `role_changed_at` counts as not logged in (`401`) at Auth and at Core, within 30 s (cache
|
||||
TTL); the frontend then refreshes and repeats the request, and the refresh reads the stored role. A role set by
|
||||
SQL (the first admin) sets no `role_changed_at`; it works from the next refresh. There is never 0 admins: every
|
||||
write that can remove an admin first locks the active admins (`shared/account/ActiveAdmins`, `SELECT ... FOR
|
||||
UPDATE`), the actor must still be one of them, and the last admin cannot delete the own account.
|
||||
- **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or
|
||||
`@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the
|
||||
active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
|
||||
@@ -63,8 +80,9 @@ To Be Played is split into three repositories:
|
||||
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
|
||||
service method calling another service's method that needs more.
|
||||
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
|
||||
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth; Core accepts the token
|
||||
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
|
||||
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then
|
||||
answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as
|
||||
"Delete my account"). No action on the own account
|
||||
or on another admin. Every action and every "view as" is a row in `admin_audit`.
|
||||
Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the
|
||||
right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
|
||||
@@ -75,6 +93,13 @@ To Be Played is split into three repositories:
|
||||
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
|
||||
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
|
||||
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
|
||||
A hint can target groups, combined with AND (none = all users): without Steam, role (this role and higher, by the
|
||||
active role of the token), new users (`users.registration_date` less than N days ago), empty library (Auth only
|
||||
sends a flag; the Frontend checks it with Core). The text may use `**bold**` and `[text](url)`; the Frontend
|
||||
renders it, never as HTML. Four automatic hints (`NO_STEAM`, `EMPTY_LIBRARY`, `PILE_GREW`, `LINK_QUEUE`) are on
|
||||
by default; a PLATFORM_ADMIN turns each on or off (`auto_hint`, audited). `GET /api/hints` lists the keys that are
|
||||
on and not hidden for the user; the Frontend checks the conditions. X hides one for 7 days, "Do not show again"
|
||||
for good (`auto_hint_hidden`).
|
||||
- **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no
|
||||
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
|
||||
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
|
||||
@@ -83,9 +108,15 @@ To Be Played is split into three repositories:
|
||||
set it (`PUT /api/users/settings/welcome-seen`). V24 sets it for every existing user, so only users registered after
|
||||
the deploy get the page.
|
||||
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
|
||||
to the admin page), and to the user "we got your registration", then "your account is active" or "your
|
||||
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
|
||||
to the admin page), and to the user "confirm your address" (`CONFIRM_EMAIL`, sent first; the admin mail follows the click), then "your account is active" or "your
|
||||
registration was denied", later "your account is blocked" and "your account will be deleted from <date>", and "reset your password" (`PASSWORD_RESET`, the link) and "your password was changed" (`PASSWORD_CHANGED`, after a reset or a change). The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
|
||||
the user's app language. Plain text only. See "Mail flow".
|
||||
- **Broadcast mail**: a PLATFORM_ADMIN writes one mail with subject and text in English and German (plain text),
|
||||
and sends it to all active users or to picked ones, each in their app language. Every active user gets it.
|
||||
"Send test to me" mails the draft to the admin only, in the admin's language. One `mail_broadcast` row keeps the
|
||||
texts and the counts (recipients, sent, failed); `BROADCAST_SEND` goes to `admin_audit`. The sender takes account
|
||||
mails first and stays under `APP_MAIL_DAILY_LIMIT` per 24 h, 10 of them kept for account mails. A recipient who is
|
||||
blocked or deleted before the send counts as failed.
|
||||
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
||||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
||||
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
|
||||
@@ -98,7 +129,12 @@ To Be Played is split into three repositories:
|
||||
| Method | Path | Auth |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/registration/new` | public |
|
||||
| `POST` | `/api/registration/confirm` (`{token, password}`; `204`; `410 CONFIRMATION_INVALID`) | public |
|
||||
| `POST` | `/api/registration/resend` (`{email}`; always `204`) | public |
|
||||
| `POST` | `/api/auth/login` | public, sets both cookies |
|
||||
| `POST` | `/api/auth/password-reset` (`{email}`; always `204`; `429 TOO_MANY_ATTEMPTS`: 5 per IP and 15 min) | public |
|
||||
| `POST` | `/api/auth/password-reset/confirm` (`{token, password}`; `204`; `410 RESET_INVALID`; `429`) | public |
|
||||
| `POST` | `/api/auth/password` (`{currentPassword, newPassword}`; `204`; `403 INVALID_PASSWORD`; `429`; under `/api/auth` for the refresh cookie, which keeps the current login) | access token |
|
||||
| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
|
||||
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
|
||||
| `GET` | `/api/users/me` | access token |
|
||||
@@ -106,44 +142,57 @@ To Be Played is split into three repositories:
|
||||
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
|
||||
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
|
||||
| `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie |
|
||||
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` UNCONFIRMED, PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/role` (`{role, expectedRole}`; `204`; `400` for an unknown role; `404 USER_NOT_FOUND`; `409 SELF_ACTION`, `ACTOR_NOT_ADMIN`, `ROLE_CHANGED` (the role is not `expectedRole`), `ALREADY_DELETED`, `TARGET_IS_BLOCKED`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/admin/users/{id}/role-changes` (`{results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}`, newest first, 50 at most; `actorEmail` null after the actor is purged) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token |
|
||||
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/hints` (`{results}`: running hints for this user not closed for good, newest first, with `emptyLibraryOnly`; `automatic`: keys of the automatic hints that are on and not hidden) | access token |
|
||||
| `PUT` | `/api/hints/auto/{key}/snooze` (hide 7 days; `204`; `404 HINT_NOT_FOUND`) | access token |
|
||||
| `PUT` | `/api/hints/auto/{key}/done` (hide for good; `204`; `404 HINT_NOT_FOUND`) | access token |
|
||||
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token |
|
||||
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | 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}` and optional `targetNoSteam`, `targetRole` (this role and higher), `targetNewDays` (1-365), `targetEmptyLibrary`; `201 {id}`, `400 INVALID_HINT` for the time range; `400 VALIDATION_FAILED` for a bad `targetNewDays` or `targetRole`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/hints/auto/{key}` (`{enabled}`; `204`; `404 HINT_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
|
||||
| `PUT` | `/api/news/seen` (`204`) | access token |
|
||||
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `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 |
|
||||
| `PUT` | `/api/users/settings/appearance` | access token |
|
||||
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
||||
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token |
|
||||
| `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | access token |
|
||||
| `GET` | `/api/admin/broadcasts` (`{results}` with the counts, newest first) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/admin/broadcasts/recipient-count?userIds=` (`{count}`; no `userIds` = all) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/broadcasts` (`{subjectEn, subjectDe, bodyEn, bodyDe, userIds}`; `201 {recipientCount}`; `409 NO_RECIPIENTS`) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/broadcasts/test` (same body; `204`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
||||
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
||||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||
| `PUT` | `/api/users/settings/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` | `/.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 |
|
||||
|
||||
Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
|
||||
- PostgreSQL 17, Flyway migrations
|
||||
- JJWT with Ed25519 keys
|
||||
- Spring Boot Actuator + Micrometer (Prometheus)
|
||||
- Spring Boot Actuator (`/actuator/health` only)
|
||||
- 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
|
||||
|
||||
@@ -162,9 +211,9 @@ may log in and is shown to others" uses `shared/account/ActiveUser`.
|
||||
A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the
|
||||
display name and the settings. A new setting needs no new repository method.
|
||||
Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write,
|
||||
`activeUsers` (the filter's "blocked or deleted?" check per request) after 30 seconds. Every write evicts what it
|
||||
changes. A request that read the database just before a block or delete can put the old answer back, so the short
|
||||
TTL is the real limit: a blocked or deleted user keeps a valid access token for up to 30 s.
|
||||
`activeUsers` (the filter's "blocked or deleted, and when did the role change?" check per request) after 30 seconds.
|
||||
Every write evicts what it changes. A request that read the database just before a block or delete can put the old answer back, so the short
|
||||
TTL is the real limit: a blocked or deleted user, or a token from before a role change, works for up to 30 s.
|
||||
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
|
||||
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
|
||||
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
|
||||
@@ -179,6 +228,8 @@ use each other.
|
||||
`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the
|
||||
event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port
|
||||
`shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set.
|
||||
Broadcasts (`BroadcastService`, `mail_broadcast`) queue one row per recipient; the same job sends them after the
|
||||
account mails and under the daily limit.
|
||||
|
||||
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
|
||||
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
||||
@@ -203,8 +254,8 @@ sequenceDiagram
|
||||
B->>A: POST /api/auth/login
|
||||
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
||||
B->>C: API call with tbp_access
|
||||
C->>A: GET /.well-known/jwks.json (new kid only)
|
||||
A-->>C: Public keys (Ed25519)
|
||||
C->>A: GET /.well-known/jwks.json (unknown kid only)
|
||||
A-->>C: Current public key (Ed25519, one JWK)
|
||||
C-->>B: 200, or 401 when the access token expired
|
||||
B->>A: POST /api/auth/refresh with tbp_refresh
|
||||
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
||||
@@ -216,8 +267,8 @@ sequenceDiagram
|
||||
|
||||
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
||||
can read them.
|
||||
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them
|
||||
again only for an unknown `kid`.
|
||||
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes the current key only; Core
|
||||
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.
|
||||
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
||||
|
||||
@@ -277,18 +328,27 @@ sequenceDiagram
|
||||
participant B as Browser (Frontend)
|
||||
participant A as Auth
|
||||
participant M as SMTP (Hostinger)
|
||||
B->>A: admin sends a broadcast
|
||||
A->>A: mail_broadcast row + one outbox row per active user
|
||||
B->>A: register / admin decides / admin blocks / delete account
|
||||
A->>A: event + mail_outbox row (PENDING), one transaction
|
||||
loop every minute (MailOutboxService)
|
||||
A->>M: up to 20 due rows
|
||||
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
|
||||
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
|
||||
end
|
||||
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days
|
||||
Note over A: after 10 tries FAILED and a warning in the log, SENT rows are removed after 30 days
|
||||
```
|
||||
|
||||
### Registration flow
|
||||
|
||||
A registration is a request: the account is PENDING until a PLATFORM_ADMIN decides. While 20 wait, the
|
||||
A registration is a request: the account is UNCONFIRMED until the address owner opens the link in the mail (24 hours,
|
||||
single use, only its SHA-256 is stored; a resend replaces it) and chooses the password there, then PENDING until a
|
||||
PLATFORM_ADMIN decides. The registration form has no password: an unconfirmed user has none and cannot log in (login
|
||||
answers the same `401` as for an unknown address), so nobody can set the password of an address that is not theirs.
|
||||
The admin list shows an unconfirmed request as such; it cannot be accepted, the admin can delete it for good. The badge counts PENDING only and the admin mail follows the confirmation. The nightly cleanup deletes it after 7 days (not while a resent link is still valid), also in dry run.
|
||||
Registering again with an unconfirmed address is a resend: a new link, no second row. At most 3 confirmation mails per
|
||||
address and hour (over all IPs, registration and resend together); over that the answer stays the same and no mail is
|
||||
sent. Unconfirmed requests count toward the 20 that wait. While 20 wait, the
|
||||
registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21.
|
||||
|
||||
```mermaid
|
||||
@@ -297,10 +357,13 @@ sequenceDiagram
|
||||
participant A as Auth
|
||||
participant D as Admin
|
||||
U->>A: POST /api/registration/new
|
||||
A->>A: users.status = PENDING, two mail rows
|
||||
A->>A: users.status = UNCONFIRMED, one mail row
|
||||
A-->>U: mail "confirm your address" (link)
|
||||
U->>A: POST /api/registration/confirm {token, password}
|
||||
A->>A: users.status = PENDING, password hash stored
|
||||
A-->>D: mail "a new registration waits"
|
||||
A-->>U: mail "we got your registration"
|
||||
U->>A: login: 403 ACCOUNT_PENDING
|
||||
Note over A: nightly: UNCONFIRMED older than 7 days is deleted
|
||||
D->>A: PUT /api/admin/users/{id}/decision {accepted}
|
||||
alt accepted
|
||||
A->>A: status = ACTIVE
|
||||
@@ -311,20 +374,47 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
### Password reset flow
|
||||
|
||||
The link is valid for 1 hour and works once: the reset is one `UPDATE ... WHERE password_reset_hash = ? AND expires > now
|
||||
AND <active>` that also clears the hash. At most 3 reset mails per address and hour (over all IPs) and 5 requests per IP
|
||||
per 15 minutes; failed confirms are limited to 10 per IP. Other logins keep their access token until it expires (up to
|
||||
15 minutes); their refresh tokens end at once.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant A as Auth
|
||||
participant D as Admin
|
||||
U->>A: POST /api/auth/password-reset {email}
|
||||
A->>A: active user: store token hash + expiry, one mail row
|
||||
A-->>U: 204 (same for any address)
|
||||
A-->>U: mail "reset your password" (link with token)
|
||||
U->>A: POST /api/auth/password-reset/confirm {token, password}
|
||||
A->>A: new password, hash cleared, all refresh tokens revoked
|
||||
A-->>U: 204 and mail "your password was changed"
|
||||
Note over D,A: POST /api/admin/users/{id}/password-reset sends the same mail (active users only) and writes the audit log
|
||||
```
|
||||
|
||||
### Steam ID and block sync to Core
|
||||
|
||||
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
||||
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
|
||||
(`AccountStatePublisher`); the nightly cleanup sends every user. The call runs on the task executor, so neither the answer
|
||||
nor a database connection waits for Core; a failed call only warns.
|
||||
(`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
|
||||
cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display
|
||||
name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name:
|
||||
`PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket:
|
||||
`ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile
|
||||
hides (#27). The call runs on the task executor, so neither the answer nor a database
|
||||
connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Auth
|
||||
participant C as Core
|
||||
A->>A: link / unlink / block / unblock (commit)
|
||||
A->>C: POST /internal/accounts/states {userId, steamId, blocked}
|
||||
C->>C: blocked_user, Steam ID in user_steam_sync
|
||||
A->>A: link / unlink / block / unblock / name change (commit)
|
||||
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}
|
||||
C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name
|
||||
Note over A,C: nightly 04:15
|
||||
A->>C: PUT /internal/accounts/states (every user)
|
||||
```
|
||||
@@ -343,7 +433,7 @@ other, then start them in this order (Core also starts without Auth and finds it
|
||||
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
||||
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
||||
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
||||
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
|
||||
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.
|
||||
|
||||
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
||||
@@ -363,24 +453,31 @@ Needs Java 25, Docker and your own keys.
|
||||
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
||||
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
|
||||
|
||||
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core
|
||||
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no
|
||||
cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every
|
||||
local port.
|
||||
Auth has no `application-dev.yml`: any profile but `local` (also none) runs the same way and takes the keys from the
|
||||
environment. `local` must not be combined with `prod`.
|
||||
|
||||
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:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
||||
| `JWT_KID` | Key id written into the token header |
|
||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||
| `JWT_KID` | Key id in the token header (default `tbp-auth-1`); Auth refuses to start when it is blank |
|
||||
| `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_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
||||
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
||||
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
|
||||
| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
|
||||
| `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host |
|
||||
| `APP_MAIL_DAILY_LIMIT` | Mails per rolling 24 h, all kinds together (default 100, Hostinger's free mailbox; 1000 Business Starter, 3000 Business Standard / Premium). Broadcast rows keep 10 free |
|
||||
|
||||
Generate a keypair:
|
||||
|
||||
@@ -390,6 +487,13 @@ openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY
|
||||
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,7 +15,8 @@ public class CacheConfig {
|
||||
|
||||
// After write, not after access: an entry another path changed without an evict still ages out.
|
||||
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);
|
||||
|
||||
@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
|
||||
* verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user counts as
|
||||
* not logged in.
|
||||
* verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user, and a token
|
||||
* issued before the last role change of its user, count as not logged in.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -48,7 +48,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
.flatMap(Optional::stream)
|
||||
.map(jwtService::parseLoginToken)
|
||||
.flatMap(Optional::stream)
|
||||
.filter(each -> activeUserCheck.isActive(each.getIdAsLong()))
|
||||
.filter(each -> activeUserCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
|
||||
.findFirst();
|
||||
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
|
||||
@@ -19,6 +19,7 @@ import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
@@ -65,15 +66,17 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
||||
}
|
||||
|
||||
@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()
|
||||
.header().keyId(jwtConfig.getKid()).and()
|
||||
.issuer(ISSUER)
|
||||
.audience().add(LOGIN_AUDIENCE).and()
|
||||
.subject(userId)
|
||||
.claim(ROLE_CLAIM, role.name())
|
||||
.issuedAt(new Date(System.currentTimeMillis()))
|
||||
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis()));
|
||||
.issuedAt(new Date(issuedAt))
|
||||
.expiration(new Date(now + sessionProperties.getAccessTokenTtl().toMillis()));
|
||||
|
||||
if (steamId != null) {
|
||||
builder.claim("steamId", steamId);
|
||||
@@ -139,7 +142,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
||||
return Optional.empty();
|
||||
}
|
||||
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 _) {
|
||||
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.
|
||||
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.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()
|
||||
// The active role: an admin who views as USER gets 403 here.
|
||||
.requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN")
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
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.ForgotPasswordRequest;
|
||||
import auth_service.modules.account.api.dto.request.ResetPasswordRequest;
|
||||
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.ClientIp;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -18,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class AccountController {
|
||||
|
||||
private final AccountService accountService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
private final AuthCookies authCookies;
|
||||
|
||||
@DeleteMapping(value = "/api/users/me")
|
||||
@@ -28,4 +37,29 @@ public class AccountController {
|
||||
authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
|
||||
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;
|
||||
|
||||
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 org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -22,8 +24,20 @@ public class AccountExceptionController {
|
||||
.body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(TooManyDeleteAttemptsException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(TooManyDeleteAttemptsException ex) {
|
||||
@ExceptionHandler(PasswordResetInvalidException.class)
|
||||
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)
|
||||
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
|
||||
.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;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One user as Core copies it.
|
||||
*
|
||||
* @param roleChangedAt null when the role never changed on the admin page
|
||||
* @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser}
|
||||
* @param publicProfile may be followed in Core, see {@link auth_service.shared.account.PublicUser}
|
||||
* @param profileSections the profile parts the user shows ({@link auth_service.shared.account.ProfileSections}),
|
||||
* null without a settings row
|
||||
*/
|
||||
public record CoreAccountState(long userId, Long steamId, boolean blocked) {
|
||||
public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
|
||||
boolean listed, boolean publicProfile, List<String> profileSections) {
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package auth_service.modules.account.repo;
|
||||
|
||||
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.RowMapper;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
@@ -8,8 +14,12 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Array;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -49,6 +59,54 @@ public class AccountRepository {
|
||||
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() {
|
||||
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class);
|
||||
}
|
||||
@@ -59,17 +117,42 @@ public class AccountRepository {
|
||||
}
|
||||
|
||||
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
|
||||
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), !rs.getBoolean("enabled"));
|
||||
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
|
||||
!rs.getBoolean("enabled"),
|
||||
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(),
|
||||
rs.getBoolean("listed"), rs.getBoolean("public_profile"), sections(rs.getArray("profile_tiles")));
|
||||
|
||||
// Stored as "ID:SIZE", see PublicProfileRepository.tile.
|
||||
private static List<String> sections(Array storedTiles) throws SQLException {
|
||||
if (storedTiles == null) {
|
||||
return null;
|
||||
}
|
||||
return ProfileSections.of(Arrays.stream((String[]) storedTiles.getArray())
|
||||
.map(stored -> stored.replaceFirst(":[^:]*$", ""))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, "
|
||||
+ "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed, "
|
||||
+ "COALESCE(" + PublicUser.of("u", "s") + ", FALSE) AS public_profile, s.profile_tiles "
|
||||
+ "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id";
|
||||
|
||||
public Optional<CoreAccountState> findState(long userId) {
|
||||
return jdbc.query("SELECT id, steam_id, enabled FROM users WHERE id = ?", STATE_ROW, userId).stream().findFirst();
|
||||
return jdbc.query(STATE_SELECT + " WHERE u.id = ?", STATE_ROW, userId).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every user, also the flagged ones.
|
||||
*/
|
||||
public List<CoreAccountState> findAllStates() {
|
||||
return jdbc.query("SELECT id, steam_id, enabled FROM users ORDER BY id", STATE_ROW);
|
||||
return jdbc.query(STATE_SELECT + " ORDER BY u.id", STATE_ROW);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the active admins until the transaction ends, see {@link ActiveAdmins}.
|
||||
*/
|
||||
public List<Long> lockActiveAdmins() {
|
||||
return jdbc.queryForList(ActiveAdmins.LOCK, Long.class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,4 +171,14 @@ public class AccountRepository {
|
||||
}
|
||||
return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings go by their ON DELETE CASCADE. registration_date is a naive UTC timestamp. A row whose resent link
|
||||
* is still valid stays.
|
||||
*/
|
||||
public int deleteUnconfirmedBefore(LocalDateTime cutoff) {
|
||||
return jdbc.update("""
|
||||
DELETE FROM users WHERE status = 'UNCONFIRMED' AND registration_date < ?
|
||||
AND (email_confirmation_expires_at IS NULL OR email_confirmation_expires_at < now())""", cutoff);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -20,6 +22,7 @@ import java.util.List;
|
||||
public class AccountCleanupService {
|
||||
|
||||
static final String ZONE = "Europe/Berlin";
|
||||
static final Duration UNCONFIRMED_RETENTION = Duration.ofDays(7);
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final CoreAccountService coreAccountService;
|
||||
@@ -37,6 +40,7 @@ public class AccountCleanupService {
|
||||
|
||||
@Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE)
|
||||
public void runNightly() {
|
||||
purgeUnconfirmed();
|
||||
try {
|
||||
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
|
||||
} catch (RuntimeException e) {
|
||||
@@ -48,6 +52,19 @@ public class AccountCleanupService {
|
||||
pruneOrphans();
|
||||
}
|
||||
|
||||
// Not gated by dryRun: an unconfirmed row never was an account and has nothing in Core.
|
||||
private void purgeUnconfirmed() {
|
||||
try {
|
||||
int deleted = accountRepository.deleteUnconfirmedBefore(
|
||||
LocalDateTime.now(Clock.systemUTC()).minus(UNCONFIRMED_RETENTION));
|
||||
if (deleted > 0) {
|
||||
log.info("Account cleanup: deleted {} unconfirmed registrations", deleted);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Account cleanup: purge of unconfirmed registrations failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendStates() {
|
||||
try {
|
||||
coreAccountService.replaceStates(accountRepository.findAllStates());
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package auth_service.modules.account.service;
|
||||
|
||||
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.shared.account.AccountDeleter;
|
||||
import auth_service.shared.account.AccountStatePublisher;
|
||||
import auth_service.shared.mail.AccountMailer;
|
||||
import auth_service.shared.security.AttemptLimiter;
|
||||
import auth_service.shared.security.OpaqueToken;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -36,18 +39,20 @@ public class AccountService implements AccountDeleter {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final CoreAccountService coreAccountService;
|
||||
private final AccountMailer accountMailer;
|
||||
private final AccountStatePublisher accountStatePublisher;
|
||||
private final TransactionOperations transactions;
|
||||
private final Duration retention;
|
||||
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
|
||||
|
||||
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
|
||||
CoreAccountService coreAccountService, AccountMailer accountMailer,
|
||||
TransactionOperations transactions,
|
||||
AccountStatePublisher accountStatePublisher, TransactionOperations transactions,
|
||||
@Value("${app.account-cleanup.retention}") Duration retention) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.coreAccountService = coreAccountService;
|
||||
this.accountMailer = accountMailer;
|
||||
this.accountStatePublisher = accountStatePublisher;
|
||||
this.transactions = transactions;
|
||||
this.retention = retention;
|
||||
}
|
||||
@@ -59,23 +64,34 @@ public class AccountService implements AccountDeleter {
|
||||
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
|
||||
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||
public void deleteAccount(long userId, String password) {
|
||||
String key = String.valueOf(userId);
|
||||
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
|
||||
throw new TooManyDeleteAttemptsException(LOCK.toSeconds());
|
||||
}
|
||||
String key = acquireAttempt(userId);
|
||||
Optional<String> hash = accountRepository.findPasswordHashOfActive(userId);
|
||||
if (hash.isEmpty()) {
|
||||
// Already flagged, e.g. a second click: the answer is the same.
|
||||
attemptLimiter.release(key);
|
||||
return;
|
||||
}
|
||||
if (!passwordEncoder.matches(password, hash.get())) {
|
||||
throw new InvalidPasswordException();
|
||||
}
|
||||
attemptLimiter.reset(key);
|
||||
checkPassword(key, password, hash.get());
|
||||
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
|
||||
@RequiresRole(Role.PLATFORM_ADMIN)
|
||||
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
|
||||
@@ -84,10 +100,29 @@ public class AccountService implements AccountDeleter {
|
||||
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.
|
||||
private boolean flagAndTellCore(long userId) {
|
||||
Instant now = Instant.now();
|
||||
boolean flagged = Boolean.TRUE.equals(transactions.execute(status -> {
|
||||
if (accountRepository.lockActiveAdmins().equals(List.of(userId))) {
|
||||
throw new LastAdminException();
|
||||
}
|
||||
if (accountRepository.flagDeleted(userId, now) == 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -101,8 +136,10 @@ public class AccountService implements AccountDeleter {
|
||||
try {
|
||||
coreAccountService.markDeleted(List.of(userId));
|
||||
} 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());
|
||||
// The push carries listed=false and is retried; the nightly cleanup stays the last fallback.
|
||||
accountStatePublisher.publish(userId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,30 +4,46 @@ import auth_service.modules.account.repo.AccountRepository;
|
||||
import auth_service.shared.account.AccountStatePublisher;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Slf4j
|
||||
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 CoreAccountService coreAccountService;
|
||||
private final Executor executor;
|
||||
private final TaskScheduler scheduler;
|
||||
|
||||
public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService,
|
||||
@Qualifier("applicationTaskExecutor") Executor executor) {
|
||||
@Qualifier("applicationTaskExecutor") Executor executor,
|
||||
@Qualifier("taskScheduler") TaskScheduler scheduler) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.coreAccountService = coreAccountService;
|
||||
this.executor = executor;
|
||||
this.scheduler = scheduler;
|
||||
for (int i = 0; i < LOCK_STRIPES; i++) {
|
||||
sendLocks[i] = new Object();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -36,20 +52,30 @@ public class CoreAccountStatePublisher implements AccountStatePublisher {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
executor.execute(() -> send(userId));
|
||||
executor.execute(() -> send(userId, 0));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
executor.execute(() -> send(userId));
|
||||
executor.execute(() -> send(userId, 0));
|
||||
}
|
||||
}
|
||||
|
||||
private void send(long userId) {
|
||||
private void send(long userId, int attempt) {
|
||||
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) {
|
||||
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
|
||||
e.getMessage());
|
||||
if (attempt < RETRY_DELAYS.size()) {
|
||||
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.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.AdminUserResponse;
|
||||
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.AdminUserPage;
|
||||
import auth_service.modules.admin.model.RoleChange;
|
||||
import auth_service.modules.admin.service.AdminUserService;
|
||||
import auth_service.modules.admin.service.RoleChangeService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
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.GetMapping;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -28,6 +34,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class AdminUserController {
|
||||
|
||||
private final AdminUserService adminUserService;
|
||||
private final RoleChangeService roleChangeService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
||||
@@ -58,15 +65,47 @@ public class AdminUserController {
|
||||
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}")
|
||||
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
|
||||
adminUserService.delete(principal.getIdAsLong(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping(value = "/{userId}/unconfirmed")
|
||||
public ResponseEntity<Void> deleteUnconfirmed(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable long userId) {
|
||||
adminUserService.deleteUnconfirmed(principal.getIdAsLong(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/password-reset")
|
||||
public ResponseEntity<Void> sendPasswordReset(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable long userId) {
|
||||
adminUserService.sendPasswordReset(principal.getIdAsLong(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static AdminUserResponse toApi(AdminUser user) {
|
||||
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
|
||||
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(),
|
||||
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
|
||||
public class AdminActionRefusedException extends RuntimeException {
|
||||
/**
|
||||
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING or TARGET_IS_PENDING
|
||||
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING, TARGET_IS_PENDING, TARGET_IS_UNCONFIRMED,
|
||||
* NOT_UNCONFIRMED, NOT_ACTIVE, ACTOR_NOT_ADMIN, TARGET_IS_BLOCKED or ROLE_CHANGED
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @param status PENDING (registration waits for an admin), ACTIVE or DENIED
|
||||
* @param status UNCONFIRMED (address not confirmed yet), PENDING (registration waits for an admin), ACTIVE or DENIED
|
||||
* @param deletedAt set while the account waits for the nightly cleanup
|
||||
*/
|
||||
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @param actorEmail null after the actor is purged
|
||||
*/
|
||||
public record RoleChange(long actorUserId, String actorEmail, Role oldRole, Role newRole, Instant changedAt) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @param deleted flagged as deleted, or denied
|
||||
* @param roleChangedAt null when the role never changed on the admin page
|
||||
*/
|
||||
public record RoleTarget(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
|
||||
}
|
||||
@@ -2,5 +2,5 @@ package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
public record TargetState(Role role, boolean deleted, boolean pending) {
|
||||
public record TargetState(Role role, boolean deleted, boolean pending, boolean unconfirmed) {
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public class AdminUserRepository {
|
||||
|
||||
/**
|
||||
* @param query part of the email or display name, any case; empty for all users
|
||||
* @param status PENDING, ACTIVE or DENIED; empty for all
|
||||
* @param status UNCONFIRMED, PENDING, ACTIVE or DENIED; empty for all
|
||||
*/
|
||||
public List<AdminUser> findPage(String query, String status, int page, int size) {
|
||||
Object[] match = matchArgs(query);
|
||||
@@ -74,7 +74,7 @@ public class AdminUserRepository {
|
||||
public Optional<TargetState> findState(long userId) {
|
||||
return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?",
|
||||
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null,
|
||||
"PENDING".equals(rs.getString("status"))),
|
||||
"PENDING".equals(rs.getString("status")), "UNCONFIRMED".equals(rs.getString("status"))),
|
||||
userId).stream().findFirst();
|
||||
}
|
||||
|
||||
@@ -95,6 +95,15 @@ public class AdminUserRepository {
|
||||
Timestamp.from(now), userId) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete, like the nightly purge: the address is free at once. Settings go by their ON DELETE CASCADE.
|
||||
*
|
||||
* @return false when the user is not unconfirmed (any more)
|
||||
*/
|
||||
public boolean deleteUnconfirmed(long userId) {
|
||||
return jdbc.update("DELETE FROM users WHERE id = ? AND status = 'UNCONFIRMED'", userId) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking also ends every login of the user.
|
||||
*
|
||||
|
||||
@@ -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.shared.account.AccountDeleter;
|
||||
import auth_service.shared.account.AccountStatePublisher;
|
||||
import auth_service.shared.account.PasswordResetSender;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.mail.AccountMailer;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
@@ -33,6 +34,7 @@ public class AdminUserService {
|
||||
private final AccountDeleter accountDeleter;
|
||||
private final AccountStatePublisher accountStatePublisher;
|
||||
private final AccountMailer accountMailer;
|
||||
private final PasswordResetSender passwordResetSender;
|
||||
|
||||
public AdminUserPage findUsers(String query, String status, int page, int size) {
|
||||
String search = query == null ? "" : query.trim();
|
||||
@@ -71,7 +73,7 @@ public class AdminUserService {
|
||||
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
|
||||
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
|
||||
TargetState target = notPending(checkedTarget(actorUserId, targetUserId));
|
||||
TargetState target = notARequest(checkedTarget(actorUserId, targetUserId));
|
||||
if (target.deleted()) {
|
||||
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
|
||||
}
|
||||
@@ -90,13 +92,40 @@ public class AdminUserService {
|
||||
* transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag.
|
||||
*/
|
||||
public void delete(long actorUserId, long targetUserId) {
|
||||
notPending(checkedTarget(actorUserId, targetUserId));
|
||||
notARequest(checkedTarget(actorUserId, targetUserId));
|
||||
if (accountDeleter.deleteByAdmin(targetUserId)) {
|
||||
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
|
||||
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only for a request whose address was never confirmed: a hard delete, no mail. The nightly purge does the same
|
||||
* after 7 days.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteUnconfirmed(long actorUserId, long targetUserId) {
|
||||
if (!checkedTarget(actorUserId, targetUserId).unconfirmed()
|
||||
|| !adminUserRepository.deleteUnconfirmed(targetUserId)) {
|
||||
throw new AdminActionRefusedException("NOT_UNCONFIRMED", "The registration is confirmed already");
|
||||
}
|
||||
adminAuditLog.record(actorUserId, "DELETE_UNCONFIRMED", targetUserId, null);
|
||||
log.info("Admin {} deleted the unconfirmed registration of user {}", actorUserId, targetUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same mail as "Forgot password". The admin never sees or sets a password.
|
||||
*/
|
||||
@Transactional
|
||||
public void sendPasswordReset(long actorUserId, long targetUserId) {
|
||||
checkedTarget(actorUserId, targetUserId);
|
||||
if (!passwordResetSender.sendByAdmin(targetUserId)) {
|
||||
throw new AdminActionRefusedException("NOT_ACTIVE", "Only an active account can get a reset mail");
|
||||
}
|
||||
adminAuditLog.record(actorUserId, "SEND_PASSWORD_RESET", targetUserId, null);
|
||||
log.info("Admin {} sent a password reset to user {}", actorUserId, targetUserId);
|
||||
}
|
||||
|
||||
private TargetState checkedTarget(long actorUserId, long targetUserId) {
|
||||
if (actorUserId == targetUserId) {
|
||||
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account");
|
||||
@@ -109,10 +138,13 @@ public class AdminUserService {
|
||||
return target;
|
||||
}
|
||||
|
||||
private static TargetState notPending(TargetState target) {
|
||||
private static TargetState notARequest(TargetState target) {
|
||||
if (target.pending()) {
|
||||
throw new AdminActionRefusedException("TARGET_IS_PENDING", "Accept or deny the registration first");
|
||||
}
|
||||
if (target.unconfirmed()) {
|
||||
throw new AdminActionRefusedException("TARGET_IS_UNCONFIRMED", "Delete the unconfirmed registration instead");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
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.response.AdminHintListResponse;
|
||||
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.model.AdminHint;
|
||||
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.service.AdminHintService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
@@ -32,14 +35,18 @@ public class AdminHintController {
|
||||
@GetMapping
|
||||
public ResponseEntity<AdminHintListResponse> findAll() {
|
||||
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
|
||||
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody CreateHintRequest request) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -49,9 +56,19 @@ public class AdminHintController {
|
||||
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) {
|
||||
Hint hint = admin.hint();
|
||||
HintTarget target = hint.target();
|
||||
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.HintResponse;
|
||||
import auth_service.modules.hint.model.AutoHint;
|
||||
import auth_service.modules.hint.model.Hint;
|
||||
import auth_service.modules.hint.service.HintService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
@@ -23,8 +24,23 @@ public class HintController {
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream()
|
||||
.map(HintController::toApi).toList()));
|
||||
long userId = principal.getIdAsLong();
|
||||
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")
|
||||
@@ -35,6 +51,6 @@ public class HintController {
|
||||
|
||||
private static HintResponse toApi(Hint hint) {
|
||||
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;
|
||||
|
||||
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.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
@@ -8,8 +11,12 @@ import jakarta.validation.constraints.Size;
|
||||
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,
|
||||
@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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
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}
|
||||
*/
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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.HintKind;
|
||||
import auth_service.modules.hint.model.HintStatus;
|
||||
import auth_service.modules.hint.model.HintTarget;
|
||||
import auth_service.modules.hint.model.NewHint;
|
||||
import auth_service.shared.security.Role;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Repository
|
||||
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(
|
||||
rs.getLong("id"),
|
||||
@@ -25,7 +33,9 @@ public class HintRepository {
|
||||
rs.getString("text_de"),
|
||||
HintKind.valueOf(rs.getString("kind")),
|
||||
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;
|
||||
|
||||
@@ -33,13 +43,56 @@ public class HintRepository {
|
||||
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);
|
||||
// registration_date is naive UTC
|
||||
LocalDateTime utc = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
|
||||
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 > ?
|
||||
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = ?)
|
||||
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, at, at, userId);
|
||||
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = u.id)
|
||||
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) {
|
||||
@@ -61,10 +114,14 @@ public class HintRepository {
|
||||
}
|
||||
|
||||
public long insert(NewHint hint, long createdBy) {
|
||||
HintTarget target = hint.target();
|
||||
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(),
|
||||
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) {
|
||||
@@ -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 > ?",
|
||||
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.InvalidHintException;
|
||||
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.repo.HintRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
@@ -38,8 +40,8 @@ public class AdminHintService {
|
||||
throw new InvalidHintException("The end must be in the future");
|
||||
}
|
||||
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()),
|
||||
actorUserId);
|
||||
long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt(),
|
||||
hint.target()), actorUserId);
|
||||
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
|
||||
log.info("Admin {} created hint {}", actorUserId, id);
|
||||
return id;
|
||||
@@ -55,4 +57,16 @@ public class AdminHintService {
|
||||
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;
|
||||
|
||||
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.repo.HintRepository;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
@@ -8,6 +9,7 @@ import auth_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@@ -16,10 +18,24 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class HintService {
|
||||
|
||||
private static final Duration SNOOZE = Duration.ofDays(7);
|
||||
|
||||
private final HintRepository hintRepository;
|
||||
|
||||
public List<Hint> activeHints(long userId) {
|
||||
return hintRepository.findActiveFor(userId, Instant.now());
|
||||
public List<Hint> activeHints(long userId, Role role) {
|
||||
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) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class User {
|
||||
@@ -18,6 +20,7 @@ public class User {
|
||||
private boolean pending;
|
||||
@Builder.Default
|
||||
private Role role = Role.USER;
|
||||
private Instant roleChangedAt;
|
||||
|
||||
public boolean canLogin() {
|
||||
return enabled && !deleted && !pending;
|
||||
|
||||
@@ -18,5 +18,11 @@ public interface MinimalUserRepository {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
||||
|
||||
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, "
|
||||
+ "role FROM users ";
|
||||
+ "role, role_changed_at FROM users ";
|
||||
|
||||
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
|
||||
.id(rs.getLong("id"))
|
||||
@@ -29,6 +29,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
||||
.deleted(rs.getBoolean("deleted"))
|
||||
.pending(rs.getBoolean("pending"))
|
||||
.role(Role.parse(rs.getString("role")))
|
||||
.roleChangedAt(rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant())
|
||||
.build();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@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
|
||||
public void updateLoginDate(long 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);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,15 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
|
||||
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
|
||||
public Optional<RefreshToken> findByHashForUpdate(String tokenHash) {
|
||||
return entityManager.createQuery(
|
||||
|
||||
@@ -4,6 +4,7 @@ import auth_service.modules.login.model.User;
|
||||
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||
import auth_service.shared.security.ActiveUserCheck;
|
||||
import auth_service.shared.security.Public;
|
||||
import auth_service.shared.security.UserTokenState;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -20,7 +21,9 @@ public class ActiveUserService implements ActiveUserCheck {
|
||||
|
||||
@Override
|
||||
@Cacheable(value = "activeUsers", key = "#userId")
|
||||
public boolean isActive(long userId) {
|
||||
return minimalUserRepository.findById(userId).filter(User::canLogin).isPresent();
|
||||
public UserTokenState state(long userId) {
|
||||
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;
|
||||
|
||||
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.AccountPendingException;
|
||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||
@@ -23,7 +22,6 @@ import java.util.UUID;
|
||||
public class LoginService {
|
||||
private final MinimalUserRepository minimalUserRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TokenGenerator tokenGenerator;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final CacheManager cacheManager;
|
||||
@@ -32,11 +30,10 @@ public class LoginService {
|
||||
private final String dummyHash;
|
||||
|
||||
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
|
||||
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService,
|
||||
RefreshTokenService refreshTokenService, CacheManager cacheManager) {
|
||||
LoginAttemptService loginAttemptService, RefreshTokenService refreshTokenService,
|
||||
CacheManager cacheManager) {
|
||||
this.minimalUserRepository = minimalUserRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.tokenGenerator = tokenGenerator;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.refreshTokenService = refreshTokenService;
|
||||
this.cacheManager = cacheManager;
|
||||
@@ -45,7 +42,8 @@ public class LoginService {
|
||||
|
||||
/**
|
||||
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
|
||||
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right password.
|
||||
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right
|
||||
* password. An unconfirmed user has no password and fails like a wrong one.
|
||||
*
|
||||
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
|
||||
* so its login is revoked; else it would stay valid unseen for 30 days.
|
||||
@@ -81,7 +79,6 @@ public class LoginService {
|
||||
users.evict(user.getId());
|
||||
}
|
||||
refreshTokenService.revoke(previousRefreshToken);
|
||||
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getSteamId(), user.getRole());
|
||||
return new Session(accessToken, refreshTokenService.startFamily(user.getId()));
|
||||
return refreshTokenService.startSession(user.getId(), hash);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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.Session;
|
||||
import auth_service.modules.login.model.User;
|
||||
@@ -46,9 +47,22 @@ public class RefreshTokenService {
|
||||
private final SessionProperties sessionProperties;
|
||||
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
|
||||
public String startFamily(long userId) {
|
||||
return issue(userId, UUID.randomUUID(), Instant.now(), null);
|
||||
public Session startSession(long userId, String passwordHash) {
|
||||
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();
|
||||
}
|
||||
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));
|
||||
if (found.isEmpty()) {
|
||||
return Optional.empty();
|
||||
@@ -110,7 +130,8 @@ public class RefreshTokenService {
|
||||
if (switching && stored != Role.USER) {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package auth_service.modules.mail.api;
|
||||
|
||||
import auth_service.modules.mail.api.dto.request.BroadcastRequest;
|
||||
import auth_service.modules.mail.api.dto.response.BroadcastListResponse;
|
||||
import auth_service.modules.mail.api.dto.response.BroadcastResponse;
|
||||
import auth_service.modules.mail.api.dto.response.RecipientCountResponse;
|
||||
import auth_service.modules.mail.api.dto.response.SentBroadcastResponse;
|
||||
import auth_service.modules.mail.model.Broadcast;
|
||||
import auth_service.modules.mail.model.BroadcastText;
|
||||
import auth_service.modules.mail.service.BroadcastService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/broadcasts")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminBroadcastController {
|
||||
|
||||
private final BroadcastService broadcastService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<BroadcastListResponse> findAll() {
|
||||
return ResponseEntity.ok(new BroadcastListResponse(broadcastService.findAll().stream()
|
||||
.map(AdminBroadcastController::toApi).toList()));
|
||||
}
|
||||
|
||||
@GetMapping("/recipient-count")
|
||||
public ResponseEntity<RecipientCountResponse> recipientCount(@RequestParam(required = false) List<Long> userIds) {
|
||||
return ResponseEntity.ok(new RecipientCountResponse(broadcastService.countRecipients(userIds)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<SentBroadcastResponse> send(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody BroadcastRequest request) {
|
||||
int queued = broadcastService.send(principal.getIdAsLong(), toText(request), request.userIds());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(new SentBroadcastResponse(queued));
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Void> sendTest(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody BroadcastRequest request) {
|
||||
broadcastService.sendTest(principal.getIdAsLong(), toText(request));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static BroadcastText toText(BroadcastRequest request) {
|
||||
return new BroadcastText(request.subjectEn().strip(), request.subjectDe().strip(),
|
||||
request.bodyEn().strip(), request.bodyDe().strip());
|
||||
}
|
||||
|
||||
private static BroadcastResponse toApi(Broadcast broadcast) {
|
||||
BroadcastText text = broadcast.text();
|
||||
return new BroadcastResponse(broadcast.id(), text.subjectEn(), text.subjectDe(), text.bodyEn(), text.bodyDe(),
|
||||
broadcast.createdBy(), broadcast.createdAt(), broadcast.recipientCount(), broadcast.sentCount(),
|
||||
broadcast.failedCount());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package auth_service.modules.mail.api;
|
||||
|
||||
import auth_service.modules.mail.exception.NoRecipientsException;
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice(assignableTypes = AdminBroadcastController.class)
|
||||
@Order(value = 1)
|
||||
public class BroadcastExceptionController {
|
||||
|
||||
@ExceptionHandler(NoRecipientsException.class)
|
||||
public ResponseEntity<ErrorResponse> conflict(NoRecipientsException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("NO_RECIPIENTS", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package auth_service.modules.mail.api.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Plain text, never HTML.
|
||||
*
|
||||
* @param userIds null = every active user; the test mail ignores it
|
||||
*/
|
||||
public record BroadcastRequest(@NotBlank @Size(max = 150) String subjectEn, @NotBlank @Size(max = 150) String subjectDe,
|
||||
@NotBlank @Size(max = 5000) String bodyEn, @NotBlank @Size(max = 5000) String bodyDe,
|
||||
List<Long> userIds) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.mail.api.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record BroadcastListResponse(List<BroadcastResponse> results) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth_service.modules.mail.api.dto.response;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record BroadcastResponse(long id, String subjectEn, String subjectDe, String bodyEn, String bodyDe,
|
||||
long createdBy, Instant createdAt, int recipientCount, int sentCount,
|
||||
int failedCount) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package auth_service.modules.mail.api.dto.response;
|
||||
|
||||
public record RecipientCountResponse(long count) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package auth_service.modules.mail.api.dto.response;
|
||||
|
||||
public record SentBroadcastResponse(int recipientCount) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth_service.modules.mail.exception;
|
||||
|
||||
public class NoRecipientsException extends RuntimeException {
|
||||
|
||||
public NoRecipientsException() {
|
||||
super("No active user among the recipients");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.mail.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Broadcast(long id, BroadcastText text, long createdBy, Instant createdAt, int recipientCount,
|
||||
int sentCount, int failedCount) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package auth_service.modules.mail.model;
|
||||
|
||||
public record BroadcastText(String subjectEn, String subjectDe, String bodyEn, String bodyDe) {
|
||||
}
|
||||
@@ -29,20 +29,27 @@ public enum MailTemplate {
|
||||
%s
|
||||
"""),
|
||||
|
||||
REGISTRATION_RECEIVED(
|
||||
"Your ToBePlayed registration has arrived", """
|
||||
// %1$s greeting, %2$s link.
|
||||
CONFIRM_EMAIL(
|
||||
"Confirm your email address for ToBePlayed", """
|
||||
Hello %s,
|
||||
|
||||
thank you for your registration. An administrator checks each new account.
|
||||
You get a mail as soon as it is decided.
|
||||
thank you for your registration. Please confirm your email address with this link (valid for 24 hours):
|
||||
%s
|
||||
|
||||
After that an administrator checks your request, and you get a mail as soon as it is decided.
|
||||
If you did not register, ignore this mail.
|
||||
|
||||
ToBePlayed
|
||||
""",
|
||||
"Deine ToBePlayed-Registrierung ist angekommen", """
|
||||
"Bestätige deine E-Mail-Adresse für ToBePlayed", """
|
||||
Hallo %s,
|
||||
|
||||
danke für deine Registrierung. Ein Administrator prüft jedes neue Konto.
|
||||
Du bekommst eine Mail, sobald entschieden ist.
|
||||
danke für deine Registrierung. Bitte bestätige deine E-Mail-Adresse über diesen Link (24 Stunden gültig):
|
||||
%s
|
||||
|
||||
Danach prüft ein Administrator deine Anfrage, und du bekommst eine Mail, sobald entschieden ist.
|
||||
Wenn du dich nicht registriert hast, ignoriere diese Mail.
|
||||
|
||||
ToBePlayed
|
||||
"""),
|
||||
@@ -117,6 +124,67 @@ public enum MailTemplate {
|
||||
Alle deine Daten werden ab dem %s gelöscht.
|
||||
|
||||
ToBePlayed
|
||||
"""),
|
||||
|
||||
// %1$s greeting, %2$s link.
|
||||
PASSWORD_RESET(
|
||||
"Reset your ToBePlayed password", """
|
||||
Hello %s,
|
||||
|
||||
someone asked to reset the password of your account. Choose a new password with this link (valid for 1 hour, once):
|
||||
%s
|
||||
|
||||
If it was not you, ignore this mail; your password stays as it is.
|
||||
|
||||
ToBePlayed
|
||||
""",
|
||||
"Setze dein ToBePlayed-Passwort zurück", """
|
||||
Hallo %s,
|
||||
|
||||
jemand möchte das Passwort deines Kontos zurücksetzen. Wähle über diesen Link ein neues Passwort (1 Stunde gültig, einmal nutzbar):
|
||||
%s
|
||||
|
||||
Wenn du das nicht warst, ignoriere diese Mail; dein Passwort bleibt, wie es ist.
|
||||
|
||||
ToBePlayed
|
||||
"""),
|
||||
|
||||
// %1$s greeting, %2$s link to the reset page.
|
||||
PASSWORD_CHANGED(
|
||||
"Your ToBePlayed password was changed", """
|
||||
Hello %s,
|
||||
|
||||
the password of your account was changed, and your other logins were ended.
|
||||
If it was not you, reset your password at once:
|
||||
%s
|
||||
and answer this mail.
|
||||
|
||||
ToBePlayed
|
||||
""",
|
||||
"Dein ToBePlayed-Passwort wurde geändert", """
|
||||
Hallo %s,
|
||||
|
||||
das Passwort deines Kontos wurde geändert, und deine anderen Anmeldungen wurden beendet.
|
||||
Wenn du das nicht warst, setze dein Passwort sofort zurück:
|
||||
%s
|
||||
und antworte auf diese Mail.
|
||||
|
||||
ToBePlayed
|
||||
"""),
|
||||
|
||||
// Admin text only: %1$s subject, %2$s body.
|
||||
BROADCAST(
|
||||
"%1$s", """
|
||||
%2$s
|
||||
|
||||
--
|
||||
You get this mail because you have an account on ToBePlayed.
|
||||
""",
|
||||
"%1$s", """
|
||||
%2$s
|
||||
|
||||
--
|
||||
Du bekommst diese Mail, weil du ein Konto bei ToBePlayed hast.
|
||||
""");
|
||||
|
||||
private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||
@@ -135,14 +203,14 @@ public enum MailTemplate {
|
||||
*/
|
||||
public Mail render(String language, Object... args) {
|
||||
Mail mail = isGerman(language) ? de : en;
|
||||
return new Mail(mail.subject(), mail.body().formatted(args));
|
||||
return new Mail(mail.subject().formatted(args), mail.body().formatted(args));
|
||||
}
|
||||
|
||||
public static String date(String language, LocalDate date) {
|
||||
return (isGerman(language) ? DATE_DE : DATE_EN).format(date);
|
||||
}
|
||||
|
||||
private static boolean isGerman(String language) {
|
||||
public static boolean isGerman(String language) {
|
||||
return "de".equals(language);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
package auth_service.modules.mail.model;
|
||||
|
||||
public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts) {
|
||||
/**
|
||||
* @param broadcastId null for an account mail
|
||||
*/
|
||||
public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts,
|
||||
Long broadcastId) {
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth_service.modules.mail.repo;
|
||||
|
||||
import auth_service.modules.mail.model.Broadcast;
|
||||
import auth_service.modules.mail.model.BroadcastText;
|
||||
import auth_service.modules.mail.model.Mail;
|
||||
import auth_service.shared.account.ActiveUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Repository
|
||||
public class BroadcastRepository {
|
||||
|
||||
static final String RECIPIENTS = "FROM users u JOIN user_settings s ON s.user_id = u.id WHERE "
|
||||
+ ActiveUser.of("u");
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final NamedParameterJdbcTemplate namedJdbc;
|
||||
|
||||
public BroadcastRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) {
|
||||
this.jdbc = jdbc;
|
||||
this.namedJdbc = namedJdbc;
|
||||
}
|
||||
|
||||
public long countRecipients(List<Long> userIds) {
|
||||
return Objects.requireNonNull(namedJdbc.queryForObject("SELECT COUNT(*) " + recipients(userIds),
|
||||
ids(userIds), Long.class));
|
||||
}
|
||||
|
||||
public long insert(BroadcastText text, long createdBy, Instant createdAt) {
|
||||
return Objects.requireNonNull(jdbc.queryForObject("""
|
||||
INSERT INTO mail_broadcast (subject_en, subject_de, body_en, body_de, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING id""", Long.class, text.subjectEn(), text.subjectDe(),
|
||||
text.bodyEn(), text.bodyDe(), createdBy, Timestamp.from(createdAt)));
|
||||
}
|
||||
|
||||
/**
|
||||
* One outbox row per recipient, in the language of the user (English when none is set).
|
||||
*
|
||||
* @return the number of rows queued
|
||||
*/
|
||||
public int queue(long broadcastId, List<Long> userIds, Mail en, Mail de) {
|
||||
return namedJdbc.update("""
|
||||
INSERT INTO mail_outbox (kind, user_id, to_address, subject, body, broadcast_id)
|
||||
SELECT 'BROADCAST', u.id, u.email, CASE WHEN s.language = 'de' THEN :subjectDe ELSE :subjectEn END,
|
||||
CASE WHEN s.language = 'de' THEN :bodyDe ELSE :bodyEn END, :broadcastId
|
||||
""" + recipients(userIds), ids(userIds)
|
||||
.addValue("subjectEn", en.subject()).addValue("bodyEn", en.body())
|
||||
.addValue("subjectDe", de.subject()).addValue("bodyDe", de.body())
|
||||
.addValue("broadcastId", broadcastId));
|
||||
}
|
||||
|
||||
public void setRecipientCount(long broadcastId, int count) {
|
||||
jdbc.update("UPDATE mail_broadcast SET recipient_count = ? WHERE id = ?", count, broadcastId);
|
||||
}
|
||||
|
||||
public List<Broadcast> findAll() {
|
||||
return jdbc.query("SELECT * FROM mail_broadcast ORDER BY created_at DESC, id DESC",
|
||||
(rs, row) -> new Broadcast(rs.getLong("id"),
|
||||
new BroadcastText(rs.getString("subject_en"), rs.getString("subject_de"),
|
||||
rs.getString("body_en"), rs.getString("body_de")),
|
||||
rs.getLong("created_by"), rs.getTimestamp("created_at").toInstant(),
|
||||
rs.getInt("recipient_count"), rs.getInt("sent_count"), rs.getInt("failed_count")));
|
||||
}
|
||||
|
||||
private static String recipients(List<Long> userIds) {
|
||||
if (userIds == null) {
|
||||
return RECIPIENTS;
|
||||
}
|
||||
// An empty list would make "IN ()", a syntax error.
|
||||
return RECIPIENTS + (userIds.isEmpty() ? " AND FALSE" : " AND u.id IN (:ids)");
|
||||
}
|
||||
|
||||
private static MapSqlParameterSource ids(List<Long> userIds) {
|
||||
return new MapSqlParameterSource("ids", userIds);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@@ -39,16 +40,19 @@ public class MailOutboxRepository {
|
||||
|
||||
public List<OutboxRow> findDue(Instant now, int limit) {
|
||||
return jdbc.query("""
|
||||
SELECT id, kind, to_address, subject, body, attempts FROM mail_outbox
|
||||
SELECT id, kind, to_address, subject, body, attempts, broadcast_id FROM mail_outbox
|
||||
WHERE status = 'PENDING' AND next_attempt_at <= ?
|
||||
ORDER BY id LIMIT ?""",
|
||||
ORDER BY broadcast_id NULLS FIRST, id LIMIT ?""",
|
||||
(rs, row) -> new OutboxRow(rs.getLong("id"), rs.getString("kind"), rs.getString("to_address"),
|
||||
rs.getString("subject"), rs.getString("body"), rs.getInt("attempts")),
|
||||
rs.getString("subject"), rs.getString("body"), rs.getInt("attempts"), rs.getObject("broadcast_id", Long.class)),
|
||||
Timestamp.from(now), limit);
|
||||
}
|
||||
|
||||
public void markSent(long id, Instant now) {
|
||||
jdbc.update("UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL WHERE id = ?",
|
||||
jdbc.update("""
|
||||
WITH row AS (UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL
|
||||
WHERE id = ? RETURNING broadcast_id)
|
||||
UPDATE mail_broadcast b SET sent_count = sent_count + 1 FROM row WHERE b.id = row.broadcast_id""",
|
||||
Timestamp.from(now), id);
|
||||
}
|
||||
|
||||
@@ -58,10 +62,37 @@ public class MailOutboxRepository {
|
||||
}
|
||||
|
||||
public void markFailed(long id, int attempts, String error) {
|
||||
jdbc.update("UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ? WHERE id = ?",
|
||||
jdbc.update("""
|
||||
WITH row AS (UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ?
|
||||
WHERE id = ? RETURNING broadcast_id)
|
||||
UPDATE mail_broadcast b SET failed_count = failed_count + 1 FROM row WHERE b.id = row.broadcast_id""",
|
||||
attempts, shorten(error), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the pending broadcast rows whose user is no longer active since the queue time.
|
||||
*
|
||||
* @return the number of rows failed
|
||||
*/
|
||||
public int failBroadcastRowsOfInactiveUsers() {
|
||||
return Objects.requireNonNull(jdbc.queryForObject("""
|
||||
WITH skipped AS (
|
||||
UPDATE mail_outbox o SET status = 'FAILED', last_error = 'recipient inactive'
|
||||
WHERE o.status = 'PENDING' AND o.broadcast_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 %s AND u.id = o.user_id)
|
||||
RETURNING o.broadcast_id),
|
||||
counted AS (
|
||||
UPDATE mail_broadcast b SET failed_count = failed_count + c.n
|
||||
FROM (SELECT broadcast_id, COUNT(*) AS n FROM skipped GROUP BY broadcast_id) c
|
||||
WHERE b.id = c.broadcast_id)
|
||||
SELECT COUNT(*) FROM skipped""".formatted(BroadcastRepository.RECIPIENTS), Integer.class));
|
||||
}
|
||||
|
||||
public int countSentSince(Instant since) {
|
||||
return Objects.requireNonNull(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE sent_at >= ?",
|
||||
Integer.class, Timestamp.from(since)));
|
||||
}
|
||||
|
||||
public void deleteSentBefore(Instant cutoff) {
|
||||
jdbc.update("DELETE FROM mail_outbox WHERE status = 'SENT' AND sent_at < ?", Timestamp.from(cutoff));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package auth_service.modules.mail.service;
|
||||
|
||||
import auth_service.modules.mail.exception.NoRecipientsException;
|
||||
import auth_service.modules.mail.model.Broadcast;
|
||||
import auth_service.modules.mail.model.BroadcastText;
|
||||
import auth_service.modules.mail.model.Mail;
|
||||
import auth_service.modules.mail.model.MailTemplate;
|
||||
import auth_service.modules.mail.model.Recipient;
|
||||
import auth_service.modules.mail.repo.BroadcastRepository;
|
||||
import auth_service.modules.mail.repo.MailOutboxRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiresRole(Role.PLATFORM_ADMIN)
|
||||
@Slf4j
|
||||
public class BroadcastService {
|
||||
|
||||
private final BroadcastRepository broadcasts;
|
||||
private final MailOutboxRepository outbox;
|
||||
private final AdminAuditLog adminAuditLog;
|
||||
|
||||
public BroadcastService(BroadcastRepository broadcasts, MailOutboxRepository outbox, AdminAuditLog adminAuditLog) {
|
||||
this.broadcasts = broadcasts;
|
||||
this.outbox = outbox;
|
||||
this.adminAuditLog = adminAuditLog;
|
||||
}
|
||||
|
||||
public List<Broadcast> findAll() {
|
||||
return broadcasts.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param userIds null = every active user
|
||||
*/
|
||||
public long countRecipients(List<Long> userIds) {
|
||||
return broadcasts.countRecipients(userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param userIds null = every active user
|
||||
* @return the number of mails queued
|
||||
* @throws NoRecipientsException when nobody is left; nothing is stored then
|
||||
*/
|
||||
@Transactional
|
||||
public int send(long actorUserId, BroadcastText text, List<Long> userIds) {
|
||||
Mail en = MailTemplate.BROADCAST.render("en", text.subjectEn(), text.bodyEn());
|
||||
Mail de = MailTemplate.BROADCAST.render("de", text.subjectDe(), text.bodyDe());
|
||||
long id = broadcasts.insert(text, actorUserId, Instant.now());
|
||||
int queued = broadcasts.queue(id, userIds, en, de);
|
||||
if (queued == 0) {
|
||||
throw new NoRecipientsException();
|
||||
}
|
||||
broadcasts.setRecipientCount(id, queued);
|
||||
adminAuditLog.record(actorUserId, "BROADCAST_SEND", null, String.valueOf(id));
|
||||
log.info("Admin {} queued broadcast {} for {} users", actorUserId, id, queued);
|
||||
return queued;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mails the draft to the admin only, in the admin's language.
|
||||
*/
|
||||
@Transactional
|
||||
public void sendTest(long actorUserId, BroadcastText text) {
|
||||
Recipient admin = outbox.findRecipient(actorUserId).orElseThrow();
|
||||
boolean german = MailTemplate.isGerman(admin.language());
|
||||
Mail mail = MailTemplate.BROADCAST.render(admin.language(), german ? text.subjectDe() : text.subjectEn(),
|
||||
german ? text.bodyDe() : text.bodyEn());
|
||||
outbox.insert(MailTemplate.BROADCAST, actorUserId, admin.email(), mail);
|
||||
}
|
||||
}
|
||||
@@ -26,16 +26,21 @@ public class MailOutboxService {
|
||||
static final int MAX_ATTEMPTS = 10;
|
||||
static final Duration KEEP_SENT = Duration.ofDays(30);
|
||||
private static final int BATCH = 20;
|
||||
// Kept free of broadcast rows, so a registration mail still goes out on a broadcast day.
|
||||
static final int ACCOUNT_RESERVE = 10;
|
||||
|
||||
private final MailOutboxRepository repository;
|
||||
private final JavaMailSender sender;
|
||||
private final String from;
|
||||
private final int dailyLimit;
|
||||
|
||||
// An empty SPRING_MAIL_HOST still creates Boot's sender bean, so the host decides, not the bean.
|
||||
public MailOutboxService(MailOutboxRepository repository, ObjectProvider<JavaMailSender> sender,
|
||||
@Value("${spring.mail.host:}") String host, @Value("${app.mail.from}") String from,
|
||||
@Value("${app.mail.admin-to}") String adminTo) {
|
||||
@Value("${app.mail.admin-to}") String adminTo,
|
||||
@Value("${app.mail.daily-limit}") int dailyLimit) {
|
||||
this.repository = repository;
|
||||
this.dailyLimit = dailyLimit;
|
||||
this.from = from;
|
||||
if (host.isBlank()) {
|
||||
this.sender = null;
|
||||
@@ -45,6 +50,10 @@ public class MailOutboxService {
|
||||
if (from.isBlank() || adminTo.isBlank()) {
|
||||
throw new IllegalStateException("SPRING_MAIL_HOST is set, so APP_MAIL_FROM and APP_MAIL_ADMIN_TO are needed");
|
||||
}
|
||||
if (dailyLimit <= ACCOUNT_RESERVE) {
|
||||
throw new IllegalStateException("APP_MAIL_DAILY_LIMIT must be above " + ACCOUNT_RESERVE
|
||||
+ ", the room kept for account mails");
|
||||
}
|
||||
this.sender = sender.getObject();
|
||||
}
|
||||
|
||||
@@ -57,8 +66,18 @@ public class MailOutboxService {
|
||||
return;
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
for (OutboxRow row : repository.findDue(now, BATCH)) {
|
||||
int skipped = repository.failBroadcastRowsOfInactiveUsers();
|
||||
if (skipped > 0) {
|
||||
log.info("{} broadcast mails skipped: recipient inactive", skipped);
|
||||
}
|
||||
int budget = dailyLimit - repository.countSentSince(now.minus(Duration.ofHours(24)));
|
||||
for (OutboxRow row : repository.findDue(now, Math.min(BATCH, Math.max(budget, 0)))) {
|
||||
// Account mails come first, so from the first broadcast row on only broadcast rows follow.
|
||||
if (row.broadcastId() != null && budget <= ACCOUNT_RESERVE) {
|
||||
break;
|
||||
}
|
||||
send(row, now);
|
||||
budget--; // a failed try counts too: safer than Hostinger's own count
|
||||
}
|
||||
repository.deleteSentBefore(now.minus(KEEP_SENT));
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ public class MailService implements AccountMailer {
|
||||
this.frontendUrl = frontendUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void confirmEmail(long userId, String rawToken) {
|
||||
repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.CONFIRM_EMAIL, userId, user,
|
||||
user.greeting(), frontendUrl + "/confirm-email#" + rawToken));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registrationRequested(long userId) {
|
||||
repository.findRecipient(userId).ifPresent(user -> {
|
||||
@@ -39,7 +45,6 @@ public class MailService implements AccountMailer {
|
||||
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(),
|
||||
frontendUrl + "/admin/users"));
|
||||
}
|
||||
insertForUser(MailTemplate.REGISTRATION_RECEIVED, userId, user, user.greeting());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +72,18 @@ public class MailService implements AccountMailer {
|
||||
user.greeting(), MailTemplate.date(user.language(), deletedFrom)));
|
||||
}
|
||||
|
||||
@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) {
|
||||
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) {
|
||||
PublicProfile.Style style = profile.style();
|
||||
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.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @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 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) {}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.LocalDate;
|
||||
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 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
|
||||
|
||||
@@ -7,7 +7,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@@ -26,13 +28,25 @@ public class PublicProfileRepository {
|
||||
: 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.
|
||||
*/
|
||||
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
|
||||
return jdbc.query("""
|
||||
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
|
||||
WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
|
||||
""".formatted(ActiveUser.of("u")),
|
||||
@@ -41,7 +55,8 @@ public class PublicProfileRepository {
|
||||
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
|
||||
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
|
||||
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()),
|
||||
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.repo.PublicProfileRepository;
|
||||
import auth_service.shared.account.ProfileSections;
|
||||
import auth_service.shared.security.ProfileTicketIssuer;
|
||||
import auth_service.shared.security.Public;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Public
|
||||
@@ -20,8 +18,6 @@ public class PublicProfileService {
|
||||
|
||||
public record TicketedProfile(PublicProfile profile, String ticket) {}
|
||||
|
||||
private static final String STATS_PREFIX = "STATS_";
|
||||
|
||||
private final PublicProfileRepository publicProfileRepository;
|
||||
private final ProfileTicketIssuer profileTicketIssuer;
|
||||
|
||||
@@ -33,15 +29,9 @@ public class PublicProfileService {
|
||||
.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) {
|
||||
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id)
|
||||
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX)));
|
||||
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList();
|
||||
List<String> sections = new ArrayList<>(byStats.get(false));
|
||||
if (!statGroups.isEmpty()) {
|
||||
sections.add("STATS");
|
||||
}
|
||||
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections);
|
||||
List<String> tileIds = profile.tiles().stream().map(PublicProfile.Tile::id).toList();
|
||||
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(),
|
||||
ProfileSections.statGroups(tileIds), ProfileSections.of(tileIds));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package auth_service.modules.registration.api;
|
||||
|
||||
import auth_service.modules.registration.api.dto.mapper.ApiMapper;
|
||||
import auth_service.modules.registration.api.dto.request.ConfirmationRequest;
|
||||
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
|
||||
import auth_service.modules.registration.api.dto.request.ResendConfirmationRequest;
|
||||
import auth_service.modules.registration.api.dto.response.RegistrationResponse;
|
||||
import auth_service.modules.registration.service.RegistrationService;
|
||||
import auth_service.shared.api.ClientIp;
|
||||
@@ -28,4 +30,16 @@ public class RegistrationController {
|
||||
var userId = registrationService.register(ApiMapper.mapToModel(request), ClientIp.of(httpRequest));
|
||||
return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build());
|
||||
}
|
||||
|
||||
@PostMapping(value = "/confirm", consumes = "application/json")
|
||||
public ResponseEntity<Void> confirm(@Valid @RequestBody ConfirmationRequest request, HttpServletRequest httpRequest) {
|
||||
registrationService.confirm(request.token(), request.password(), ClientIp.of(httpRequest));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/resend", consumes = "application/json")
|
||||
public ResponseEntity<Void> resend(@Valid @RequestBody ResendConfirmationRequest request, HttpServletRequest httpRequest) {
|
||||
registrationService.resend(request.email(), ClientIp.of(httpRequest));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.registration.api;
|
||||
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import auth_service.modules.registration.exception.ConfirmationInvalidException;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||
@@ -51,6 +52,8 @@ public class RegistrationExceptionController {
|
||||
return response(body, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ExceptionHandler(ConfirmationInvalidException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(ConfirmationInvalidException ex) {
|
||||
return response(new ErrorResponse("CONFIRMATION_INVALID", ex.getMessage()), HttpStatus.GONE);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ public abstract class ApiMapper {
|
||||
public static Registration mapToModel(RegistrationRequest registrationRequest) {
|
||||
return Registration.builder().
|
||||
email(registrationRequest.email()).
|
||||
rawPassword(registrationRequest.password()).
|
||||
displayName(registrationRequest.displayName()).
|
||||
language(registrationRequest.language()).
|
||||
build();
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package auth_service.modules.registration.api.dto.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* The password is set here, by the owner of the address, and never at registration.
|
||||
*/
|
||||
public record ConfirmationRequest(@NotBlank @Size(max = 64) String token, @NotBlank @Size(min = 8) String password) {
|
||||
|
||||
/**
|
||||
* BCrypt uses only the first 72 bytes and refuses a longer password.
|
||||
*/
|
||||
@AssertTrue(message = "The password may have at most 72 bytes")
|
||||
@JsonIgnore
|
||||
public boolean isPasswordShortEnough() {
|
||||
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ConfirmationRequest[]";
|
||||
}
|
||||
}
|
||||
+2
-15
@@ -2,22 +2,18 @@ package auth_service.modules.registration.api.dto.request;
|
||||
|
||||
import auth_service.shared.api.validation.DisplayNameFormat;
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Builder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* No Steam ID: it comes only from "Sign in through Steam", later in the settings.
|
||||
* No Steam ID: it comes only from "Sign in through Steam", later in the settings. No password: the address owner
|
||||
* sets it when confirming the address.
|
||||
*/
|
||||
@Builder
|
||||
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
|
||||
@NotBlank @Size(min = 8) String password,
|
||||
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
|
||||
// For the mails; null = never chosen.
|
||||
@Pattern(regexp = "de|en") String language) {
|
||||
@@ -25,13 +21,4 @@ public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_
|
||||
public RegistrationRequest {
|
||||
email = EmailAddress.normalize(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* BCrypt uses only the first 72 bytes and refuses a longer password.
|
||||
*/
|
||||
@AssertTrue(message = "The password may have at most 72 bytes")
|
||||
@JsonIgnore
|
||||
public boolean isPasswordShortEnough() {
|
||||
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package auth_service.modules.registration.api.dto.request;
|
||||
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record ResendConfirmationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email) {
|
||||
|
||||
public ResendConfirmationRequest {
|
||||
email = EmailAddress.normalize(email);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.registration.exception;
|
||||
|
||||
public class ConfirmationInvalidException extends RuntimeException {
|
||||
public ConfirmationInvalidException() {
|
||||
super("The confirmation link is unknown, used or expired.");
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,11 @@ package auth_service.modules.registration.model;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class Registration {
|
||||
private String email;
|
||||
@ToString.Exclude
|
||||
private String rawPassword;
|
||||
@ToString.Exclude
|
||||
private String encryptedPassword;
|
||||
private String displayName;
|
||||
private String language;
|
||||
}
|
||||
+19
-2
@@ -2,15 +2,32 @@ package auth_service.modules.registration.persistence;
|
||||
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RegisteredUserRepository {
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
boolean existsByDisplayName(String displayName);
|
||||
|
||||
/**
|
||||
* Stores the user as PENDING: an admin accepts or denies the registration.
|
||||
* Stores the user as UNCONFIRMED: the address owner confirms by link, then an admin accepts or denies it.
|
||||
*/
|
||||
long save(Registration registration);
|
||||
|
||||
long countPending();
|
||||
/**
|
||||
* UNCONFIRMED and PENDING: the requests that wait for the owner or the admin.
|
||||
*/
|
||||
long countWaiting();
|
||||
|
||||
Optional<Long> findUnconfirmedIdByEmail(String email);
|
||||
|
||||
void storeConfirmation(long userId, String tokenHash, Instant expiresAt);
|
||||
|
||||
/**
|
||||
* Single use: sets PENDING and the password hash and clears the token hash.
|
||||
*
|
||||
* @return the user id, empty when the hash is unknown, used or expired
|
||||
*/
|
||||
Optional<Long> confirm(String tokenHash, String passwordHash, Instant now);
|
||||
}
|
||||
+28
-5
@@ -7,8 +7,11 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
@@ -32,9 +35,9 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
|
||||
@Transactional
|
||||
public long save(Registration registration) {
|
||||
Long id = jdbc.queryForObject("""
|
||||
INSERT INTO users (email, password, enabled, display_name, registration_date, status)
|
||||
VALUES (?, ?, TRUE, ?, ?, 'PENDING') RETURNING id""", Long.class,
|
||||
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
|
||||
INSERT INTO users (email, enabled, display_name, registration_date, status)
|
||||
VALUES (?, TRUE, ?, ?, 'UNCONFIRMED') RETURNING id""", Long.class,
|
||||
registration.getEmail(), registration.getDisplayName(),
|
||||
LocalDateTime.now(Clock.systemUTC()));
|
||||
if (id == null) {
|
||||
throw new IllegalStateException("No id for the new user");
|
||||
@@ -46,8 +49,28 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countPending() {
|
||||
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status = 'PENDING'", Long.class);
|
||||
public long countWaiting() {
|
||||
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status IN ('UNCONFIRMED', 'PENDING')", Long.class);
|
||||
return count != null ? count : 0L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Long> findUnconfirmedIdByEmail(String email) {
|
||||
return jdbc.queryForList("SELECT id FROM users WHERE email = ? AND status = 'UNCONFIRMED'", Long.class,
|
||||
EmailAddress.normalize(email)).stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeConfirmation(long userId, String tokenHash, Instant expiresAt) {
|
||||
jdbc.update("UPDATE users SET email_confirmation_hash = ?, email_confirmation_expires_at = ? WHERE id = ?",
|
||||
tokenHash, Timestamp.from(expiresAt), userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Long> confirm(String tokenHash, String passwordHash, Instant now) {
|
||||
return jdbc.queryForList("""
|
||||
UPDATE users SET status = 'PENDING', password = ?, email_confirmation_hash = NULL, email_confirmation_expires_at = NULL
|
||||
WHERE email_confirmation_hash = ? AND email_confirmation_expires_at > ? AND status = 'UNCONFIRMED'
|
||||
RETURNING id""", Long.class, passwordHash, tokenHash, Timestamp.from(now)).stream().findFirst();
|
||||
}
|
||||
}
|
||||
+12
@@ -18,6 +18,10 @@ public class RegistrationAttemptService {
|
||||
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
|
||||
|
||||
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
|
||||
static final int MAX_CONFIRMATION_MAILS_PER_ADDRESS = 3;
|
||||
static final Duration CONFIRMATION_MAIL_WINDOW = Duration.ofHours(1);
|
||||
|
||||
private final AttemptLimiter confirmationMails = new AttemptLimiter(CONFIRMATION_MAIL_WINDOW);
|
||||
private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION);
|
||||
|
||||
public void acquire(String clientIp) {
|
||||
@@ -38,4 +42,12 @@ public class RegistrationAttemptService {
|
||||
throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* At most 3 confirmation mails per address and hour over all IPs, so nobody can fill a stranger's inbox or the
|
||||
* daily mail budget. The caller answers the same when it is false.
|
||||
*/
|
||||
public boolean tryConfirmationMail(String email) {
|
||||
return confirmationMails.tryAcquire(email, MAX_CONFIRMATION_MAILS_PER_ADDRESS);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
package auth_service.modules.registration.service;
|
||||
|
||||
import auth_service.modules.registration.exception.ConfirmationInvalidException;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||
import auth_service.shared.mail.AccountMailer;
|
||||
import auth_service.shared.security.OpaqueToken;
|
||||
import auth_service.shared.security.Public;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@Public
|
||||
@RequiredArgsConstructor
|
||||
@@ -23,32 +29,71 @@ public class RegistrationService {
|
||||
private final AccountMailer accountMailer;
|
||||
|
||||
static final int MAX_PENDING = 20;
|
||||
static final Duration CONFIRMATION_TTL = Duration.ofHours(24);
|
||||
|
||||
/**
|
||||
* Stores a request that waits for the admin. When MAX_PENDING wait already, it is refused and counts as a failed
|
||||
* attempt of the IP.
|
||||
* Stores an unconfirmed request without a password and mails the confirmation link. The address owner sets the
|
||||
* password when confirming; the admin sees the request after that. When MAX_PENDING requests wait already, it is
|
||||
* refused and counts as a failed attempt of the IP. An address that is already unconfirmed gets a new link and no
|
||||
* second row.
|
||||
*/
|
||||
// The user and the mail rows commit together.
|
||||
@Transactional
|
||||
public long register(Registration registration, String clientIp) {
|
||||
registrationAttemptService.acquire(clientIp);
|
||||
Optional<Long> unconfirmed = registeredUserRepository.findUnconfirmedIdByEmail(registration.getEmail());
|
||||
if (unconfirmed.isPresent()) {
|
||||
registrationAttemptService.release(clientIp);
|
||||
registrationAttemptService.countRequest(clientIp);
|
||||
sendConfirmation(unconfirmed.get(), registration.getEmail());
|
||||
return unconfirmed.get();
|
||||
}
|
||||
// ponytail: two requests at the same time can pass at 19 and make 21; a pg_advisory_xact_lock would make it
|
||||
// exact, but the limit is a brake, not a quota.
|
||||
if (registeredUserRepository.countPending() >= MAX_PENDING) {
|
||||
if (registeredUserRepository.countWaiting() >= MAX_PENDING) {
|
||||
throw new PendingLimitReachedException();
|
||||
}
|
||||
validate(registration);
|
||||
registrationAttemptService.release(clientIp);
|
||||
registrationAttemptService.countRequest(clientIp);
|
||||
|
||||
var rawPassword = registration.getRawPassword();
|
||||
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
|
||||
|
||||
long userId = registeredUserRepository.save(registration);
|
||||
accountMailer.registrationRequested(userId);
|
||||
sendConfirmation(userId, registration.getEmail());
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password of the account. A failed confirmation counts as a failed attempt of the IP.
|
||||
*/
|
||||
@Transactional
|
||||
public void confirm(String rawToken, String rawPassword, String clientIp) {
|
||||
registrationAttemptService.acquire(clientIp);
|
||||
long userId = registeredUserRepository
|
||||
.confirm(OpaqueToken.hash(rawToken), passwordEncoder.encode(rawPassword), Instant.now())
|
||||
.orElseThrow(ConfirmationInvalidException::new);
|
||||
registrationAttemptService.release(clientIp);
|
||||
accountMailer.registrationRequested(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Never fails for an unknown address: the answer must not tell whether it exists.
|
||||
*/
|
||||
@Transactional
|
||||
public void resend(String email, String clientIp) {
|
||||
registrationAttemptService.countRequest(clientIp);
|
||||
registeredUserRepository.findUnconfirmedIdByEmail(email).ifPresent(userId -> sendConfirmation(userId, email));
|
||||
}
|
||||
|
||||
// A new link replaces the old one. Over the per-address limit nothing is sent and nothing tells the caller.
|
||||
private void sendConfirmation(long userId, String email) {
|
||||
if (!registrationAttemptService.tryConfirmationMail(email)) {
|
||||
return;
|
||||
}
|
||||
String token = OpaqueToken.generate();
|
||||
registeredUserRepository.storeConfirmation(userId, OpaqueToken.hash(token), Instant.now().plus(CONFIRMATION_TTL));
|
||||
accountMailer.confirmEmail(userId, token);
|
||||
}
|
||||
|
||||
private void validate(Registration registration) {
|
||||
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
|
||||
throw new EmailAlreadyInUseException("The provided email address is already in use.");
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth_service.modules.stats.api;
|
||||
|
||||
import auth_service.modules.stats.api.dto.PeopleDto;
|
||||
import auth_service.modules.stats.api.mapper.ApiMapper;
|
||||
import auth_service.modules.stats.model.UserCountKey;
|
||||
import auth_service.modules.stats.service.UserListService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/stats")
|
||||
@RequiredArgsConstructor
|
||||
public class UserListController {
|
||||
|
||||
private final UserListService userListService;
|
||||
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<PeopleDto> getUsers(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam UserCountKey key,
|
||||
@RequestParam(defaultValue = "0") int page) {
|
||||
return ResponseEntity.ok(ApiMapper.toApi(userListService.users(principal.getIdAsLong(), key, page)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package auth_service.modules.stats.api.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Who stands behind a number. Never an id, avatar or Steam ID (#26): a private user is only in privateCount, the
|
||||
* viewer only in includesYou.
|
||||
*/
|
||||
public record PeopleDto(List<Person> listed, long listedTotal, long privateCount, boolean includesYou, int page,
|
||||
int totalPages) {
|
||||
|
||||
public record Person(String displayName) {
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package auth_service.modules.stats.api.mapper;
|
||||
|
||||
import auth_service.modules.stats.api.dto.PeopleDto;
|
||||
import auth_service.modules.stats.api.dto.PublicStatsDto;
|
||||
import auth_service.modules.stats.model.PeopleList;
|
||||
import auth_service.modules.stats.model.PublicStats;
|
||||
|
||||
public abstract class 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) {
|
||||
return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth_service.modules.stats.model;
|
||||
|
||||
/**
|
||||
* @param others the users of a set without the viewer
|
||||
* @param listed those of them shown by name
|
||||
*/
|
||||
public record PeopleCounts(long others, long listed, boolean includesYou) {
|
||||
}
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user